Files
Jackson Oursland c6988a3993 Design containers: sign saved designs with GnuPG and check signatures (#15297)
* format: let a signed save bind a container's parts

The shared container writer can now record each authoritative part's
SHA-256 while writing it (track_parts/take_parts). A blob's name already
is its digest, so the writer reuses it instead of hashing the payload
again. Provenance records use this to bind a design without reading the
file back.

No format change: this is an API addition used by signed saves.

* Design containers: optional signed provenance records

A design container may carry provenance/log.cbor: a bounded chain of
signed records whose manifests bind the exact bytes of document.json,
container.json and every authoritative payload by SHA-256. Records are
COSE_Sign1 with Ed25519 for software keys, or a detached OpenPGP signature
with the signer's public key, which GnuPG produces for a key that may be
on a smartcard. Verification of both is in-process (coset/ed25519-dalek
for COSE, rPGP for OpenPGP), so it needs no keyring and works in the
browser; the private key never enters the file.

The chain is append-only and bounded: prev links bind the exact record
bytes, oldest pins the newest record to the file's first record so a
stripped prefix is detected, and a keyless save keeps the history and is
reported as not covering the current edition. Caches and previews are
deliberately excluded. Reading the entry never fails a design open; a
newer envelope version is reported, not misread.

The writer reuses the container's new part-digest tracking, so signing
does not read a payload twice. No design-format change: the entry is
additive and optional.

* Designs: sign saves with GnuPG and verify a design's signature

file.save and file.save_as take sign: true and signing_key: each appends
one OpenPGP record to the design's provenance and reply with the signing
key's fingerprint and user id. GnuPG signs the manifest where the key
lives, so a YubiKey OpenPGP smartcard asks for its PIN through pinentry
and no secret key is copied; the save still runs on the worker thread.
A save without a key keeps the records it opened with, so the newest
record is reported as covering an earlier version.

file.verify reports the signature of a saved design (status, records,
signer, coverage, truncation) without opening it, and file.gpg_keys lists
the GnuPG secret keys a save can use, marking those on a smartcard. A
design opened with verify: true carries the report into doc.open's reply.
The session keeps the provenance chain of the file it opened, so saves
append to the right history; opening any design file loads that chain.

The browser cannot run GnuPG and says so when asked to sign.

* Preferences: Security page for signing, and signature indicators

Preferences gains a Security page, off by default: sign saved designs
with GnuPG, the signing key (empty uses GnuPG's default), and check
signatures when opening designs. Saves in the app pass the choices to
file.save; opening passes verify: true. The status line says who signed a
save or what a check found, and a signed design's tab tooltip names its
signer (or says it has not been checked in this session).

The browser has the page and can verify, but signing says GnuPG is needed.
Encryption is described as the next step and is not a setting yet.

* Designs: icons for the signature commands

file.verify and file.gpg_keys had no icon assignment, which the icon
standards gate refuses. Both use the existing shield icon.

* Signing: check on request, choose a key, warn when a save is not signed

File > Verify Signature (the application bar's menu and the macOS menu)
checks the open design's saved file on a worker thread and reports what it
found in the status line; the tab tooltip then keeps that result. It is
disabled for a design with no file yet, and the answer is discarded when
the tab changed meanwhile.

The Security page gains Choose… next to the signing key: it lists the
secret keys GnuPG can sign with (file.gpg_keys), marks those on a
smartcard and fills the field with the chosen fingerprint. Refresh lists
them again; GnuPG missing or having no keys is said where the list would
be.

An unsigned save of a design that carries earlier signatures now says so
in the status line: the earlier signature does not cover this save.
Before, only file.verify reported that.

The status of a check and the menu row are tested; the warning has a
unit test.

* Signing: show a tab shield, a Home hint, and a linked-design note

Each design tab now carries a shield beside its name, not only tooltip
text: grey when the file has records that were not checked this session,
green when they verify and cover it, amber when an earlier signature no
longer covers the file or the record format is newer, red when a check
failed. The colours are the theme's existing status tokens.

Home reads each recent design's provenance entry (never the payloads) and
adds 'signed' to its kind, with a hover line naming the newest signer, so
a design can be recognised as signed before it opens. The native Open
dialog still lists names only; this is the in-app list.

Inserting a linked design now says that the linked file is signed (not
checked); exports carry no provenance, because only a saved .sc container
has the entry, and the docs say so.

The pre-open summary is a new io API (records and newest signer without
verification), with its own test; the tab mapping, the Home label and the
existing provenance tests cover the UI paths.

* Signing: fix the review findings, from staged checks to background keys

Staged design opens now compute the requested verification on their worker
and pass it to the landing session, instead of hard-coding false and
hashing the payloads on the window thread.

file.verify reports a file that cannot be read as an error rather than as
unsigned, and checking an explicit other path no longer replaces the
active design's report. read_chain drops a chain that does not validate,
so the next save starts a new log instead of failing to append to it.

A JSON save keeps the provenance chain until it succeeds; the success
path already clears it from the written bytes. Save As JSON omits the
signing parameters.

With Check signatures on opening on, an already-open design is reused and
its saved file checked separately, rather than opening a second tab. The
background check refuses a report when a save replaced the file while it
ran. Home tags recent-file summaries with a per-path generation so an
older response cannot overwrite a newer one.

The signing-key list is read on a worker (a GnuPG subprocess can wait on an
agent or a smartcard), with a loading state in Preferences. Staged verification
and the pre-open summary keep their new tests.

The complete catalogs (ar, he, ja, vi, zh) carry the new messages.

* Accept rsa/crypto-bigint advisories for local signing; exempt file.verify and file.gpg_keys from the kernel rig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Signing: address the human review, from trust and signing policy to CI

The human review found three blocking problems and several smaller ones.

Trust: a valid signature no longer earns the green shield or a user id on
its own. Only a fingerprint in the new Preferences › Security › Trusted
signing keys list is shown verified; every other valid signature is grey,
with its fingerprint and 'key not trusted'. The list is local and empty by
default. OpenPGP verification now checks the embedded key's self-signatures
and a signing subkey's binding (and back-signature), and reports the
primary key so trusting it covers its subkeys. Home's pre-open hint shows
the fingerprint unless the key is trusted.

Cache: a design file that carries provenance never adopts its model cache,
checked or not, so the geometry shown and exported comes from the signed
timeline rather than from unprotected cache data.

Signing policy: only the app's own Save carries the signing grant, set on
the save worker's session. A script, MCP or control caller that sends
sign: true is refused. The command line still verifies but cannot sign in
this version; the docs say why.

CI and format: the two new commands have their EXEMPT lines naming their
tests; the advisory ignores document that rsa/crypto-bigint verify public
keys only and never see a private key. GPG lookup ignores relative PATH
entries and searches standard locations first. The docs now cover the
embedded public key's user ids, the missing newest-record protection, the
cache rule, the additive entry (no design-format pending file), and the
trust policy. New tests cover the refusals, trust matching, provenance
presence, and the missing-file check.

* Signing: a changed design is not shown as checked

After an edit, the last report covers the saved file, not the document in front: the tab keeps its shield grey and the tooltip says the design changed since it was checked, and the badge test now covers the trusted and untrusted cases.

* Preferences: the Security page uses the UI kit's controls

The key picker's Choose/Refresh buttons and key rows are UI kit buttons and
list rows; the UI kit guard requires it, and the native test suite runs
that guard.

* Signing: accept fingerprints as GnuPG prints them, and test bindings

The trust list split on whitespace, so a fingerprint pasted from
gpg --fingerprint was chopped into groups that never matched and the key
stayed silently untrusted. Entries are now separated by commas or newlines,
and spaces inside an entry are ignored, so the grouped form works.

verify_openpgp's comment no longer says bindings are not evaluated.

The in-process software signer now has a signing subkey and an unbound
variant: a bound subkey verifies under the primary fingerprint, and a
subkey with its binding removed is rejected. A record with a spoofed user
id shows why the trust decision uses the fingerprint, not the name.

* Space mouse test: keep the large app values on the heap

The test held several app values on its stack at once. The signing state added a little to an
already large app, and the test thread's stack overflowed; boxing them keeps it well clear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:19:51 +00:00
..

solidcraft-format

Versioned file formats: what an app needs to read every file it ever wrote, to open a file a newer build of it wrote without losing what it can't read, and to package a file with its binary payloads. It holds no app types; each app registers its own families of files and their versions, and keeps its own payload encodings.

A file starts with "format": "<prefix>N". A family's versions are, in order: the steps written out by hand for its first formats, then one folder per format (v<N>/, one file per change, each with a JSON step when older files are rewritten as they open), then the changes in flight (pending/), which make the next number until a release folds them into v<N>/. App code only knows the current format: all translation lives in the steps.

API

Item What it does
Family { app, noun, prefix, legacy_prefix, untagged, aliases, formats } One kind of file: its tag, its messages, its versions
Formats { hand, folded, pending } A family's versions: hand-written steps, folded formats and their changes, pending changes
Part { name, step } One change: its file's name, and its JSON step if it has one
Step<E> fn(&mut Value) -> Result<(), E>: a JSON-level step, returning the app's error
Alias { format, name, known_as } Another spelling of a change name that files of one format may carry
Family::current() The format this build writes
Family::format_string(n), Family::version_of(tag) A format's tag, a tag's format (the legacy prefix read too)
Family::pending_names() The changes in flight, written into a file's format_pending
Family::take_version(&mut json) A file's format and its format_pending, taken out before anything else is read; a file not of the family is refused
Family::newer(version, applied) Some(Newer) when a newer build wrote the file: a number past this build's, or a change it doesn't know
Family::upgrade(&mut json, version, applied) Run the steps that bring the file to this build's format, each once
Newer { format, reads_up_to, unknown_changes } How a file shows a newer build wrote it
FormatError This crate's refusals; the app's error takes it with From
build::lists(manifest, families) For a build script: the generated FOLDED and PENDING lists and a module per change file
newer::NewerFile What a file a newer build saved holds that this build doesn't read: new(app, &newer), notice, warnings, report, keep_section, keep_element, compare (the file against what was read of it), restore_into (put it all back on saving), mentions, needs_container, and the container's unknown parts and container
newer::leave_out_unreadable, newer::read_around Leave out, and keep, the top-level sections a file can't be read with
newer::short, newer::looks_default A reader's error's first line; a value a writer leaves out
container::Writer A ZIP container being written: part, blob (content-addressed, blobs/<sha256>.<ext>), room/space for optional caches, finish
container::Reader One being read over a seekable source, its directory checked on opening: document, limited_part, blob, limited_blob, has_part, part_names, hydrated
container::Limits The app's budgets: entries, expanded size, per-part limits, Deflate level, file size
container::Compression, container::Thumbnail, container::digest, container::is_container Fast or compact payloads; a PNG picture keyed to its file; the SHA-256 name of bytes; the ZIP signature

Registering a family

The app's build script lists the folders:

use solidcraft_format::build::Family;
// format 1 is the first record, so its folded formats start at 2 (`base` is the last format before them)
const FAMILIES: [Family; 1] = [Family { folder: "src/format", base: 1, list_prefix: "" }];
let lists = solidcraft_format::build::lists(&manifest, &FAMILIES)?;
// write lists.code to OUT_DIR, rerun on each of lists.watch

and its format module includes them, with its error type:

type Part = solidcraft_format::Part<AppError>;
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
pub const DESIGN: solidcraft_format::Family<'static, AppError> = solidcraft_format::Family {
    app: "App", noun: "design", prefix: "app/", legacy_prefix: None, untagged: None, aliases: &[],
    formats: solidcraft_format::Formats { hand: &[], folded: FOLDED, pending: PENDING },
};

A change file is v<N>/<name>.rs or pending/<name>.rs, named with lowercase letters, digits and dashes, starting with a //! note (what is saved, what an older file gets, what the last release does with a file that has it), and with pub fn step(v: &mut Value) -> Result<(), AppError> when older files are rewritten as they open.