+--------------------------------------------+ | Remote repositories and signing | +--------------------------------------------+ STATUS: DESIGN, 2026-10-06. ROADMAP 3.1. Steps 1 (Ed25519, signify formats, gsign), 2 (keyring, the local base rule, gbuild -k, bootstrap carrying .sig, Everest's key) and 3 (grepo, the index, refresh with -y) of section 9 DONE 2026-10-06, step 4 (-f, -u, dependency closure, the cache) DONE 2026-10-07; sections 7, 5 and 6 say how they came out. Step 5 next. See CLAUDE.md. Sections 1 and 10 are decided; the only open item is 10a's TLS library. Adds AUTHENTICITY to glacier: SECURITY_DESIGN.txt section 1 deferred it to "a future phase, tied to remote-repo support". This is that phase. It also unblocks gpkg/syspkg -f (ROADMAP 2.2), -u (2.1), automatic dependency closure (2.3) and glacier-bootstrap -r (GLACIER_BOOTSTRAP section 5), and closes Finding 1 of SECURITY_REVIEW_2026-07-16.md (a manifest that says PKG_REPO = "base" gets linked into /usr). Companion documents: SECURITY_DESIGN.txt (integrity, refusal matrix), PACKAGE_SCOPES.txt (repo routing), GLACIER_BOOTSTRAP.txt (pins). +--------------------------------------------+ | 1. Decisions (the user, 2026-10-06) | +--------------------------------------------+ a. Signatures: Ed25519, vendored (Monocypher's Ed25519 variant, which brings its own SHA-512), compiled into the private libglacier_verify.so next to sha256. Key and signature files are signify-compatible, so OpenBSD's signify can make and check them too. No new base dependency. b. What is signed: one INDEX per repository (repo.idx + repo.idx.sig), listing every package it serves with its sha256. Packages are verified through the hash in the signed index, i.e. through the existing sha256 path. The index has a serial; a client refuses an index older than the last one it accepted (rollback). c. Local base packages: once signing exists, `syspkg -l FILE` of a package whose manifest says base requires a signature by a key trusted for base. extra/community local installs stay sidecar-only (integrity). Manual installation keeps working: a builder makes a key and root adds it to the keyring. d. Users may add their own repositories and keys, for their own scope only (gpkg). Never read by syspkg. +--------------------------------------------+ | 2. Keys and signatures (proposal) | +--------------------------------------------+ signify's formats, byte for byte (two lines: "untrusted comment: ..." then base64): public key "Ed" | keynum[8] | pubkey[32] signature "Ed" | keynum[8] | sig[64] secret key "Ed" | "BK" | rounds[4] | salt[16] | checksum[8] | keynum[8] | seckey[64] - keynum is a random 8-byte key id; a signature names the key that made it, so verification picks the key by keynum and never tries every key in the keyring. - Signing is pure Ed25519 over the WHOLE message, as signify does. So we never sign a .gpkg directly (it would have to be read into memory whole). What gets signed is small: - a repository's repo.idx; - a local package's SIDECAR (NAME-VER.gpkg.sha256, the one line "sha256:"), as NAME-VER.gpkg.sha256.sig. Checking the sidecar against the file is the existing path; the signature makes the sidecar authentic. `signify -V -p KEY -m X.sha256` checks the same file. - Secret keys: glacier's tools read and write only UNENCRYPTED secret keys (rounds = 0, what `signify -G -n` makes): passphrase support needs bcrypt_pbkdf, which is more vendored crypto for a build-host convenience. A passphrase-protected key works by signing with signify itself (10b). New tool `gsign` (C, built with gsha256sum; a build/maintainer tool, later in "glacier-tools", never in base): gsign -G -p KEY.pub -s KEY.sec [-c COMMENT] make a keypair gsign -S -s KEY.sec -m FILE [-x SIGFILE] sign (default FILE.sig) gsign -V -p KEY.pub -m FILE [-x SIGFILE] verify gbuild gains `-k KEY.sec`: sign the sidecar it writes. +--------------------------------------------+ | 3. Keyring and repository configuration | +--------------------------------------------+ System (root-owned; syspkg applies its trust checks to these files and every ancestor, like its own lib/): /etc/glacier/keys/NAME.pub /etc/glacier/repos.conf repos.conf (libconfig, like glacier.conf): repos = ( { name = "base"; url = "https://pkg.example.org/base"; keys = [ "everest-2026" ]; }, { name = "extra"; url = "https://pkg.example.org/extra"; keys = [ "everest-2026" ]; } ); - A key is trusted ONLY for the repos that list it. That binding is what makes "base" mean something: the base index must be signed by a key listed for base, and a local base package's sidecar likewise. Several keys per repo allow rotation (old and new side by side). - Order = priority, when one name is served by several repos. - name follows the gl_valid_pkg_ident rules. url is https:// or file:// (local mirrors, tests); http:// is refused. - url is optional: an entry without one only says which keys are trusted for that repo, for a system installed and kept up by hand from signed local files (section 7). - Missing file = no remote repositories; -f/-u then refuse in one line. No defaults, as with KEEP_OLD. User (gpkg only): ~/.config/glacier/repos.conf ($XDG_CONFIG_HOME) ~/.config/glacier/keys/NAME.pub - Merged AFTER the system list (lower priority). - A user repo named "base", or named like a system repo, is refused. - System keys are trusted for system repos only; a user's keys for the user's repos only. +--------------------------------------------+ | 4. The index | +--------------------------------------------+ URL/repo.idx and URL/repo.idx.sig. libconfig, parsed only AFTER its signature checks: REPO = "base"; SERIAL = 2026100601L; # strictly increases with each publish VALID_UNTIL = 1761955200L; # unix time; required (10c) PKGS = ( { NAME = "musl"; VER = "1.2.5"; FILE = "musl-1.2.5.gpkg"; SHA256 = "sha256:3a5f..."; SIZE = 812345L; DEPS = (); CONFS = (); }, ... ); - REPO must equal the configured name the index was fetched for. - Every version a repo serves is listed (so a pinned or older version can still be fetched by -f NAME=VER later). - DEPS/CONFS repeat the manifest's, so dependency closure can be planned before downloading anything. After download, the manifest must match the entry (NAME, VER, PKG_REPO = REPO, DEPS, CONFS): a mismatch refuses the package. That is the Finding 1 rule for remote packages: a package is "base" because the base index lists it AND it says so, never because it says so. - FILE is a bare file name (gl_valid_pkg_ident rules plus ".gpkg"): no paths, no URLs. New tool `grepo` (maintainer side, glacier-tools): `grepo -s KEY.sec -r REPO DIR` reads every NAME-VER.gpkg in DIR (manifests, hashes), writes DIR/repo.idx with SERIAL = the previous one + 1 (or the date form), and signs it. The server is then any static file host. +--------------------------------------------+ | 5. Client state and fetching | +--------------------------------------------+ Inside each scope's base directory (one more entry beside index, store, ...; same ownership and modes as the rest): /repos/REPO.idx, REPO.idx.sig the last ACCEPTED index /cache/REPO/FILE downloaded packages Refresh (every -f / -u; indexes are small; 10d): 1. curl --proto =https --proto-redir =https --fail (or a file:// copy) URL/repo.idx and .sig into a temporary file in /repos/. 2. Verify the signature with a key listed for that repo (by keynum). 3. Parse. REPO must match. SERIAL must be > the stored one, or equal with identical bytes. VALID_UNTIL must be in the future. 4. rename() over the stored copy (never written in place: bug 25). Any failure keeps the old index and refuses the operation in one line: [x] Index for extra is older than the one already accepted. Fetch: download FILE into the cache under a temporary name, hash it while downloading, rename into place only if the hash matches the index, then write the sidecar FROM THE INDEX and hand the cached file to the existing install path (which re-hashes it, as today). The cache is a download optimization, not a trusted store: a cache hit is hashed again. As implemented (2026-10-06, step 3; the user chose a refresh flag, the date-form SERIAL and grepo in C): - grepo is C (src/grepo.c, libglacier-ng; built from source like gsign, installed to bin; not in base): `grepo -s KEY.sec -r REPO [-n SERIAL] [-e DAYS] DIR`. SERIAL = max(previous + 1, today (UTC) * 100 + 1), e.g. 2026100601; -n must still be above the previous one. VALID_UNTIL = now + DAYS (default 30, 1..3650). Packages are sorted by file name; each must be a regular file, its manifest must say REPO and its name must be NAME-VER.gpkg (VER as the store names versions, "%d.%d.%d"). An existing DIR/repo.idx must be a well-formed index of REPO (its SERIAL is the previous one). It is written to a mkstemp file, signed, and both renamed into place, the .sig first. - The index format is section 4's, with DEPS and CONFS as lists of { NAME = "x"; VER = "1.0.0"; } (the manifest's PKG_DEPS shape). gl_index_parse (pkg.c) takes the BYTES the signature was checked over, never a path, and is strict: exactly REPO, SERIAL > 0, VALID_UNTIL > 0, PKGS; each package exactly NAME, VER, FILE, SHA256, SIZE >= 0, DEPS, CONFS; FILE must be exactly "NAME-VER.gpkg"; SHA256 "sha256:" + 64 lowercase hex; no NAME + VER twice. A NUL or an '@' anywhere is refused before libconfig sees it ('@include'). GL_INDEX_ERR_REPO when REPO is not the repo it was fetched for. - `syspkg -y` / `gpkg -y` (--refresh) refresh every repos.conf entry with a url and stop (decision 10d still holds for -f/-u). gpkg reads the SYSTEM repos.conf and keyring until step 6 (user repos), and stores into the user's base. No repos.conf, or no entry with a url: `[x] No remote repositories are configured.` (exit 1). - /repos is made by gl_scope_state_dir (transaction.c): the base must be the scope's; repos is created with the scope's mode (0755 root / 0700 uid) and refused unless it is a real directory with exactly that owner and mode. Files are 0644 / 0600. -y holds the scope's lock (gl_begin_transaction on a live context); under -R the indexes go under the install root, checked with the install root's OWN keyring (DIR/etc/glacier; changed 2026-10-07, see "Keyrings under -R" below). - file:// is read directly (absolute paths only); https:// goes through `curl -sf --proto =https --proto-redir =https`. 64 MiB at most (GL_SIGN_MAX_MSG). Every repo is tried; exit 1 if any failed. - One line per repo: [✓] Index for base: serial 2026100601, 11 packages. [i] Index for base is up to date: serial 2026100601. [x] Cannot fetch the index for base. (a missing .sig too) [x] Index for base is not signed by a key trusted for base. [x] Malformed index for base. [x] Index fetched for base is the index of another repository. [x] Index for base is older than the one already accepted. [x] Index for base has the serial of the one already accepted, but other contents. [x] Index for base has expired. [x] Malformed /glacier/sys/repos/base.idx. (left for the admin) - The accepted copy is trusted by where it lives (root's or the user's private repos dir): it is parsed for its SERIAL, not re-verified. - Test: glacier-ng/tests/repo_alpine.sh (103 checks; 18 mutants, 12 C and 6 Lua, all caught). +--------------------------------------------+ | 6. -f, -u and dependency closure | +--------------------------------------------+ -f NAME... (ROADMAP 2.2): like -l, with the file found by name in the accepted indexes (highest version, first repo by priority). Missing dependencies are added automatically (ROADMAP 2.3's closure): the highest version satisfying the minimum, recorded `auto`. The summary lists them, marked as dependencies. Conflicts and every 2.3 check run as today, before the prompt. -u (ROADMAP 2.1): every installed package whose repo is configured and whose index has a higher version is replaced, as -lO does (backups, KEEP_OLD). Packages from repos not configured are left alone. `-u NAME...` limits it to those names. syspkg -f/-u fetch only from system repos; gpkg -f/-u from system and the user's repos. Base is still refused in user scope (library rule). As implemented (2026-10-07, step 4; M.fetch / M.upgrade in glacier-ng/lib/gpkg_common.lua). The user decided three details: - Which repo: the FIRST repo by repos.conf order that serves NAME, at its highest version. A lower-priority repo never wins by publishing a bigger version. -u stays within the installed package's repo. - A dependency installed at too old a version is UPGRADED in the same transaction from its own repo (backed up per KEEP_OLD, like -lO), keeping its auto/explicit mark; the summary shows "old -> new (dependency)". - Cache: downloads stay in /cache/REPO while a run is declined or refused; the files a commit installed are deleted after it. The rest: - Every -f/-u runs -y's refresh first; if any repo fails, the operation is refused (decision 10d). The accepted indexes are then read from /repos (gl_index_parse again; trusted by location). - Closure: for each planned package, each DEPS entry of its INDEX entry is met by a planned package, an installed one at >= VER (this scope; for a user, the system's index too), else upgraded (an installed one too old) or added (pick(): first repo serving it, recorded auto). What can't be met is left to gl_check_plan, which prints the 2.3 refusal. The summary marks additions "(dependency)". - Download: gl_scope_cache_dir (transaction.c) makes and checks /cache and /cache/REPO as gl_scope_state_dir checks repos (owner, exact mode, not a symlink). A cached file whose hash matches the index is reused; otherwise file:// is read, https:// goes through `curl -sf --proto =https --proto-redir =https` on a pipe; at most SIZE + 1 bytes are ever read, hashed on the way, into a dot-named temporary, renamed into place only if size and hash match. 0644 / 0600. - No sidecar is written: the index's sha256 is handed to verify_and_copy directly (its `vouched` argument), which re-hashes while copying into the private dir, as for -l. A base package needs no .sig of its own: the base index vouches for it. - The copy's manifest (library-read) must say exactly what the index entry says: NAME, VER, PKG_REPO = the repo, DEPS and CONFS (same order, "NAME MAJ.MIN.PAT"). Else, and for a bad hash or size: [x] extra::evil 1.0.0 does not match its index. and the cached file is deleted. Nothing to download: [x] Cannot fetch extra::foo 1.0.0. No repo serves a name: [x] foo is not in any repository. - -f of a name installed at another version needs -O (as -l); dependency upgrades and -u don't (meta.may_replace). -O still also means displacing foreign files. - -u [NAME...]: a named package not installed, or whose repo has no url, is refused (`[x] foo is not installed.` / `[x] community::foo is not in any configured repository.`); plain -u skips the latter. Nothing newer: `[✓] Nothing to do.`, exit 0. - gpkg: user scope never fetches from base (base indexes are not even considered, so `[x] core is not in any repository.`); the system's repos.conf until step 6; indexes and cache in the user's base. - syspkg -R DIR -f/-u: indexes and cache under DIR, DIR's keyring. Keyrings under -R (decided 2026-10-07, for glacier-bootstrap configuring the system's repositories): -y, -f and -u read DIR/etc/glacier/repos.conf and DIR/etc/glacier/keys, with the same trust checks, plus: each file's real path must lie inside DIR (`[x] Refusing P: it resolves outside DIR.`), so a symlink in DIR can't make the host's file stand in for DIR's. The indexes are DIR's state and DIR checks them with its own keys from then on, and a host with no glacier configuration can still bootstrap. A local `-R -l` base package's .sig is still checked with the HOST's keyring (section 7). Tests: repo_alpine.sh / fetch_alpine.sh give the image its own keyring; bootstrap_alpine.sh sections 14-16. - Test: glacier-ng/tests/fetch_alpine.sh (84 checks; 16 Lua mutants and 1 C mutant, all caught except the download-level hash check, which is EQUIVALENT: the private copy's re-hash refuses the same package and the mismatch path deletes the cached file. Kept as defence in depth: a bad download never sits in the cache under its final name). - Known gap: a run killed mid-download leaves its dot-named temporary in the cache (never read; the next fetch writes its own). +--------------------------------------------+ | 7. Local installs (decision 1c) | +--------------------------------------------+ syspkg -l FILE, manifest says base: FILE.sha256 must match (as today) AND FILE.sha256.sig must verify with a key listed for base in repos.conf. Otherwise: [x] base::musl is not signed by a key trusted for base. syspkg -l, other repos; gpkg -l: unchanged (sidecar only). glacier-bootstrap -d: carries each package's .sig into its private copies, so syspkg -R checks it. The keyring checked is the HOST's /etc/glacier (the build host's trust, as KEEP_OLD is today). Consequence for ~/Projects/everest: it needs a key; container.sh signs every base package (gbuild -k); the build host's /etc/glacier/keys and repos.conf list it. The image ships the PUBLIC key and a repos.conf so the installed system trusts its own base (since 2026-10-07 in the everest-repos package, split out of everest-bootscripts). As implemented (2026-10-06, step 2): - The repo is the one the LIBRARY reads from the verified copy (gl_read_pkg_manifest, as gl_install_pkg), never gpkg_common's pattern-matching read_manifest_fields: a manifest comment `# PKG_REPO = "extra"` would make that one say extra. - The sidecar is read ONCE; those bytes are both what the copy's hash is checked against and what the signature is checked over. So glacier-bootstrap copies the sidecar's bytes as read (it used to write a new one from the hash) next to the .sig. - The key is the one, among those listed for base, whose keynum the signature names. Any other problem with the signature (missing, malformed, other bytes, other key) is the one-line refusal above, before the prompt; one refused package refuses the batch. - repos.conf is read by gl_conf_repo_keys (pkg.c), strictly: `repos` is the only setting; each entry has name, keys and optionally url, nothing else; names and key names follow gl_valid_pkg_ident; a duplicate name, or a url other than https://X or file://X, is refused. Any of that: `[x] Malformed /etc/glacier/repos.conf.` - repos.conf and each listed key, every ancestor of them and of their real paths, must be root-owned and not group/world-writable: `[x] Refusing F: P is not root-owned, or is group/world-writable.` A listed key that is missing (`Cannot read F.`) or not a public key (`Malformed F.`) stops the run: the admin's configuration is at fault, not the package. Read only if some package is base. - gbuild -k KEY.sec signs the sidecar (gsign from $GSIGN_BIN, build/bin, or PATH), trying the key before building; a build without -k removes a stale .sig. tests/mkpkg -k likewise. - Test images (Dockerfile.*) carry a TEST key, glacier-test, listed for base, its secret at /root/glacier-test.sec. tests/keyring_alpine.sh covers this section (17 mutants, all caught). - KEEP_OLD backups are re-packed from the store, so a base backup has no signature: `syspkg -lO` refuses it until the restore interface exists (decision 10f). +--------------------------------------------+ | 8. Where the checks live | +--------------------------------------------+ Signature verification is C (libglacier_verify.so). The POLICY (which key for which repo, the base rule, rollback state) lives in the CLIs' shared module, like hash verification today, so a third-party caller of gl_install_pkg still isn't checked. ROADMAP 3.2 (move verification into the library) now covers signatures too. +--------------------------------------------+ | 9. Order of work | +--------------------------------------------+ 1. Vendored Ed25519 + signify formats in libglacier_verify.so; gsign; tests including interop with signify (Alpine's `signify` package) and hostile inputs (bad base64, wrong keynum, truncated files). 2. Keyring + repos.conf + the local base rule (section 7); gbuild -k; glacier-bootstrap carries .sig; Everest gets a key. 3. Index: grepo; refresh, verify, rollback state. DONE 2026-10-06 (section 5, "As implemented"; syspkg/gpkg -y). 4. -f with closure; then -u. DONE 2026-10-07 (section 6, "As implemented"). 5. glacier-bootstrap -r against an index (pins stay optional extra assurance). 6. User repos. Each step is tested in Docker (file:// repos need no network) and mutation-checked, as usual. +--------------------------------------------+ | 10. Decisions on the details (2026-10-06) | +--------------------------------------------+ a. Fetching on Everest: curl plus one TLS library in base. Signatures make packages authentic regardless; TLS also hides WHAT is fetched and stops a network attacker serving a stale (still valid) index. STILL OPEN: which TLS library (BearSSL or LibreSSL); decide when packaging it. Until then, everything is built and tested with file:// repos and Alpine's curl. b. No encrypted secret keys in gsign (no bcrypt_pbkdf). A passphrase-protected key signs with signify itself. c. VALID_UNTIL is REQUIRED; an expired index is refused. The maintainer sets the lifetime; grepo defaults to 30 days. d. Indexes are refreshed on every -f / -u. Offline, they refuse. e. Everest's SECRET key lives outside the project tree: ~/.config/everest/keys/everest-2026.sec (EVEREST_KEYS), made by `everest.sh keygen`, mounted read-only into the build container. The public key ships in everest-bootscripts (/etc/glacier/keys). f. Base backups (KEEP_OLD) are refused by `syspkg -lO` for want of a signature; accepted for now. The restore interface (KEEP_OLD's "later") can trust backups glacier made in its root-owned backup dir.