* feat(installed): check an installed package through the same code that checks the archive
A package that has been installed is one variant of a .lgx extracted into a
directory with manifest.json written beside it, and until now nothing could
check one. `lgx verify` takes a .lgx path, and most of what it does is archive
shape -- the variants/ layout, the root-entry whitelist, per-tar-entry path
rules -- none of which survives extraction. So a host holding an installed
directory had the choice between reimplementing a subset of the format rules or
checking nothing. Both wrong.
Three functions on the C ABI, no Qt, so the one consumer that is Qt-based can
depend on this rather than the other way round:
lgx_manifest_validate() every Manifest::validate() rule plus the parser's
field gates, over exact bytes. No directory: a
catalog validating a fetched manifest is the
second caller.
lgx_verify_installed_tree() recompute hashes["variants/<v>"] from the files on
disk. The primitive that existed nowhere: the
archive checks the "root" hash, which covers the
whole tar, and an install has one variant of it.
Typed, because this is a security verdict.
lgx_verify_installed() all of it plus main/view resolution and the icon
contract, with lgx verify's own error strings.
Nothing is forked. Manifest::validate() and validateCompleteness() are called
as they are, the icon rule is split from its archive lookup so both callers
share it, and the leaf-hash byte layout now has one implementation that the
archive and the directory both go through.
Two things needed care. Root-level assets/ extracts into the same directory as
the variant payload but is hashed under hashes["assets"], so after installation
nothing tells the two apart by path; both readings are tried, and the one that
excludes assets/ only counts if what it excluded matches hashes["assets"], so no
file ends up covered by nothing. And a QML-only ui_qml package legitimately
declares no main at all, so the completeness check is seeded with the declared
variants -- which reduces it to exactly the question an install can answer.
Verified against all 20 packages installed on a real machine (10 core modules,
10 UI plugins, manifestVersions 0.2.0-0.4.0): all OK. Tampering with a payload
byte, the icon, an added file or a deleted one is rejected on copies of the same
installs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs: list the installed-package checks in the C API reference
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(installed): prove hashes["assets"], and split assets/ per path
The root-assets fallback was unsound in both directions.
Deleting a package's whole root assets/ read as OK: the first reading
hashed every file against the variant leaf and never looked at
hashes["assets"], so for a root-assets package the untouched tree failed
that reading and only the fallback accepted it -- which made removing
exactly those files indistinguishable from a package that never had any.
A host branching on the integrity verdict got Ok. It was masked only
because the sole root asset the tooling can write is assets/icon.png,
which the icon contract independently requires.
The mirror: the split was all-or-nothing, so a variant carrying its own
assets/ that is not the icon landed in the assets bucket and neither
reading could match. `lgx verify` accepts such a package, and its
untouched install reported tampering.
Now a declared hashes["assets"] must always come out right, and the
guess at which assets/ files came from the root is per path: the
canonical icon, or the whole directory for a hand-built archive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat: own the variant vocabulary and `main` resolution, for both consumers
Two things every consumer of an installed package needs, and neither lived
where the format does.
The variant-spelling table was in the package manager, which meant the module
library could not answer "is this install for THIS host?" without it — so
ModuleDirectory takes candidate variants from its caller and enumerates none.
The table is not duplicated; it is moved, rationale intact, and reshaped into
variantSpellings(variant) so it is a function of its input rather than of the
host. The compile-time host name comes along as hostVariant(); the package
manager keeps its own override and its -dev suffix, which are its build's
business and not the format's.
`main` resolution WAS duplicated, and the copies disagreed. The package
manager returned one empty string for "not declared", "no variant matched" and
"file missing" alike, and threw on a non-string value under a matched key. The
module library reported six states, refused a main resolving outside the
directory, and refused one naming a directory. The richer contract is the one
that moves; both consumers will read it from here.
Preserved exactly, because both are load-bearing: the first matching variant
candidate wins and does NOT fall through when its named file is missing, and
an empty value under a matched key DOES fall through.
Resolution parses the raw manifest bytes rather than a Manifest, because
`main` also has a plain-string form Manifest::fromJson rejects outright, and
it touches the disk — containment and file-ness cannot be answered from the
manifest text.
C ABI: lgx_host_variant, lgx_variant_spellings, lgx_resolve_main and
lgx_free_main_file. 42 tests, 401 green.
* test: pin the lexical containment guard in both directions
`./sub/../plugin.so` resolves after normalising even though `sub` does
not exist — a deliberate change from lgpm's fs::exists() on the raw path,
because a lexical guard also refuses a `..` traversing a symlinked
intermediate. Untested until now, so it could have reverted silently.
The second test pins that normalising is not itself a way out.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(sig): let a consumer carry a package's signature, and check it against a DID of their own
lgx_extract writes variants/<v>/ and assets/ and nothing else, so a consumer
that installs a package keeps the SIGNED BYTES -- manifest.json, written from
lgx_get_manifest_json(), which returns getManifest().toJson(), the same
expression Package::signPackage() signs -- and throws away the signature over
them. An installed package therefore held evidence it could not check, and
whoever wanted to know who published it had to record their own answer and
then defend that record from everything that can write a file.
Two additions close that:
lgx_get_manifest_sig_json() the counterpart to lgx_get_manifest_json(),
so the signature can travel with the bytes.
lgx_check_manifest_signature() does `expected_did`'s key sign these bytes?
The second takes the DID as a PARAMETER, and that is the whole point of it.
Reading the DID out of the signature document, comparing it to a pin, and then
verifying with that same document's key proves only that the file agrees with
itself, which any attacker can arrange: replace the signature, replace the DID
beside it, sign with a key you own. Taking the key from the caller instead
means a match requires an Ed25519 signature under a key the attacker does not
have. `sig_json` contributes the signature bytes and nothing else.
A did:jwk embeds its public key, so this needs no keyring. The keyring answers
a different question -- is this key ANCHORED -- and lgx_verify_signature still
answers that one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(sig): pin the property the expected-DID parameter exists for
The interesting cases are the ones a self-checking implementation would
accept. A signature document supplies both a DID and a signature, so it can
always be made to agree with itself; these tests hand the checker documents
that agree with themselves perfectly and a DID that says otherwise.
AnotherKeysGenuineSignatureIsAMismatch verifies under its own DID, and is
still a mismatch for the publisher
RelabellingTheDidDoesNotChangeTheAnswer the attacker's genuine signature,
relabelled to name the publisher --
accepted by anything that compares
the document's DID to the expected
one, refused here
AGenuineSignatureOverOtherBytesIsAMismatch replay: a real signature by the
right key over a different package
Plus the boundaries the two failure classes have to keep apart: a bad expected
DID is the CALLER's error (BAD_DID), absent or malformed evidence refutes
nothing (UNUSABLE), and an empty message is a message rather than a pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>