Skip to content

Latest commit

 

History

History
545 lines (451 loc) · 40 KB

development.md

File metadata and controls

545 lines (451 loc) · 40 KB

Development & Release Guide

This guide covers day-to-day local development against the Cargo workspace and how to cut tagged, reproducible releases. All commands are run from the rust/ directory, where the workspace Cargo.toml and the Justfile live. It mirrors the Go side (go/docs/development.md); the differences are called out where they matter.

Versioning model in one minute

Every crate is versioned solely by its own git tag rust/<crate>/vX.Y.Z, bumped only when that crate changes. There is no shared version: the workspace deliberately has no workspace.package.version, an unchanged crate keeps its tag, and the number just reflects that crate's own history. The version in each crate's Cargo.toml always equals its latest tag (or the upcoming one, on the release commit).

  • Local development uses the workspace Cargo.toml, which plays the role of go.work: every in-repo crate resolves from local source through the path in [workspace.dependencies], so a library edit is used immediately by every crate that depends on it. Unlike go.work there is nothing to opt out of — Cargo always builds a workspace member from its path.
  • Sibling crates are resolved by version, not by git tag. Each in-repo entry in [workspace.dependencies] carries both a path and a version (ti-pki = { version = "0.1", path = "ti-pki" }). The path is used locally; the version is what a packaged or published crate records for its dependency. A library bump therefore has to update that line together with the crate version and release notes. This replaces just sync from the Go side.
  • Rust code always uses the snake_case form of a crate name: the package is ti-pki, the import is use ti_pki::...;.

Inspect and manage versions:

just versions          # latest tag per crate
just changed           # crates with commits since their last tag (candidates to bump)
just tag <crate> <ver> # e.g. just tag ti-pki 0.1.0  -> tags rust/ti-pki/v0.1.0

Part A — Local development (workspace)

git clone https://github.com/gematik/zero-lab.git
cd zero-lab/rust                     # the workspace Cargo.toml lives here
just tools                           # once: the pinned cargo tools

# Edit a crate
$EDITOR ti-pki/src/chain.rs

# Build / test — the change is already in effect for every workspace member
cargo build --workspace
cargo test -p ti-pki

Confirm the workspace is wiring local source:

cargo metadata --format-version 1 | jq -r '.packages[] | select(.name == "ti-pki") | .manifest_path'
# => …/zero-lab/rust/ti-pki/Cargo.toml   (a local path = the workspace resolving it locally)

Checks come in two tiers:

just check-changed  # while working: fmt, clippy, doc and tests of the crates a branch changed and their dependents; wasm32, nonprod-absent, the manifest checks and the scripts' tests only when their inputs changed
just check        # tier 1, once per branch before it merges: fmt, clippy, doc, test, wasm32, core-deps, nonprod-absent, machete, deny, vet
just check-full   # tier 1, before a release: check, the tests with default features, every feature combination, clippy on the CLI's cross targets (Docker)
just audit        # tier 2, before tagging: advisories, vet, msrv, semver

Each gate just release runs also runs earlier, so a release does not fail first on it (the mutation scores excepted, which just mutants-record measures beforehand, by hand): vet in check, the Linux and Windows builds of ti as cli-check in check-full (linted in the same cross containers cli-targets builds in), and the advisory database through deny in check (advisories, cargo audit, in audit). msrv and semver run in audit only.

Shared metadata (edition, rust-version, license, repository, authors, publish) lives in [workspace.package] and lints in [workspace.lints]; a crate's Cargo.toml only states its name, version, description, keywords and categories, and inherits the rest with <field>.workspace = true. External dependencies are pinned once in [workspace.dependencies] and inherited the same way. Cargo.lock is committed. Adding or updating a dependency makes just vet fail until the new version is audited (cargo vet certify) or covered by an imported audit; just vet-suggest lists what is outstanding.

Shared types

Vocabulary every ti-* crate must agree on — today the environment (Env) and its production split (Tier) — lives in ti-types. Its admission rule:

If it compiles in no_std and has no I/O, it may go into ti-types; otherwise it doesn't.

No HTTP, no crypto, no async, no product constants (URLs, anchors), no helper functions; the default build has no dependencies.

ti-types goes to 1.0 soon and is additive only from then on. Every enum is #[non_exhaustive], except Tier, whose split is binary by definition. A breaking redesign gets a new type instead of a major bump: a major bump would split consumers into incompatible Env types that no longer interoperate.

Environments

Environments are data, not behaviour. Library code never branches on Env; everything that differs between environments — anchor, roots.json, TSL location, the policy relaxations — is a named field of ti_pki::TrustConfig, and Env only picks a preset. The one decision code may make is the Tier, and it is made in one place: TrustConfig::validate(tier) rejects TEST-ONLY anchors and every relaxation under Tier::Prod.

The non-production presets and their TEST-ONLY anchors and roots are compiled in only with the dangerous-nonprod feature. A production binary is built without it, and just nonprod-absent proves the compiled crate then contains none of those bytes.

Operators override fields on a preset rather than adding environments — a TSL mirror, a tighter clock skew, an anchor delivered out of band:

let config = TrustConfig { tsl_url: mirror.into(), ..TrustConfig::preset_prod() };
config.validate(Tier::Prod)?;

Tests in downstream crates use TrustConfig::for_lab_ca from the test-util feature.

Signature algorithms

Every signature ti-pki checks goes through one trait, rustls_pki_types::SignatureVerificationAlgorithm, the one rustls and webpki use. A verifier is picked by the exact pair of key algorithm and signature algorithm; validation code never names a curve. TrustConfig::algorithms holds the set.

Set Contents Backend
algorithms::STANDARD ECDSA P-256/SHA-256, P-384/SHA-384 RustCrypto p256, p384
algorithms::brainpool::ALL (feature brainpool, default) ECDSA brainpoolP256r1/SHA-256, brainpoolP384r1/SHA-384 RustCrypto bp256, bp384
algorithms::rsa::ALL (feature rsa, default) RSA PKCS#1 v1.5 and PSS with SHA-256/384/512 RustCrypto rsa
algorithms::DEFAULT STANDARD plus brainpool and RSA when enabled; what the presets use —
algorithms::NIST STANDARD plus RSA when enabled, never brainpool; how a client without brainpool verifies —

Brainpool is isolated: its own module behind its own feature, referenced only by DEFAULT, and just core-deps proves the crate without it contains no brainpool arithmetic. The anchor of every environment is GEM.RCA7 (P-256), but most roots and CAs and both TSL signer CAs are brainpool keys, so the feature is on by default. Without brainpool (the feature off, or NIST configured) the roots walk ends at the first brainpool root, GEM.RCA8 forwards and GEM.RCA5 backwards, leaving GEM.RCA7 and GEM.RCA6, and no TSL verifies (TSLDET-004), which fails a load of roots and TSL together; validate still refuses a brainpool anchor instead of failing later. Other implementations — webpki's FIPS-validated aws-lc-rs set, ML-DSA once post-quantum certificates appear — plug into the same set without touching the core.

bp256/bp384 gained curve arithmetic only in 0.14 (September 2026) and are not audited. Verification handles public data, so timing is irrelevant; correctness is covered by the Wycheproof vectors (ti-pki/tests/wycheproof/, excluded from the package) and by the real TI roots in the unit tests.

RSA is needed even for an ECC-only deployment: the historical roots GEM.RCA2/6/9 are RSA keys, and the A_28419 walk passes through them to reach GEM.RCA3–5 and GEM.RCA10–11. Without the rsa feature the walk stops at the first RSA root. With it, ti-pki builds the same ten-root store as gempki from the same roots.json.

Known compromises

Accepted trade-offs of a long-lived project whose dependencies are still maturing. Each names the trigger for removing it.

Compromise Why Remove when
rsa = "=0.10.0-rc.19", a release candidate the only rsa line on the current RustCrypto stack (crypto-bigint 0.7, sha2 0.11); 0.9 would pull in a second, older stack rsa 0.10 is released: switch to "0.10"
ti releases carry unsigned SHA256SUMS signing was deferred; the checksums catch broken downloads and Homebrew pins them in the formula, but a replaced release could replace them too a release key exists: sign SHA256SUMS in just release and verify it in publish-brew
RUSTSEC-2023-0071 ignored (deny.toml, .cargo/audit.toml) the Marvin attack targets RSA private-key operations; ti-pki only verifies, on public data a patched rsa is released
The TSL signer CA does not change at run time: spec/tsl-xmldsig part E (TSLSIG-061 – 068: an anchor announced in the TSL becoming active at its StatusStartingTime, and the fallback through roots.json) is not implemented. TrustConfig::tsl_signer_anchors is a list; a TSLServiceCertChange the TSL announces is reported as the warning tsl_anchor_announced (shown by ti), and a release adds the CA to anchors::TSL_SIGNER_CAS_* gematik changes the TSL signer CA rarely (GEM.TSL-CA3 runs until 2028-05-25) and announces it weeks ahead; adding it to the list in a release is simple and auditable, while part E needs persisted announcements re-verified on load, a roots.json path with its own profile, age and OCSP checks, and signed test TSLs an installation must follow an anchor change without a release
The TSL admits no CA on its own; of its per-CA metadata only the status, the certificate types and the OCSP services decide the TSL is verified (spec/tsl-xmldsig), but a CA from it is kept only if a root from the A_28419 walk signed it. From a CA's entry, ti-pki uses its status as the CA's standing (TUC_PKI_018 step 5: in accord, or revoked with its StatusStartingTime, Tab_PKI_271: ca_revoked_in_tsl, issued_before_ca_revocation; VALID-008: no responder is asked about a CA) and the certificate types it may issue (TUC_PKI_007: cert_type_ca_not_authorized, also when the entry lists none), which makes a CA one for components, SMC-B, eGK or HBA; provider names and the other metadata decide nothing. For OCSP responders see the next row the TSL becomes a trust source of its own, admitting CAs no walked root signed
OCSP responders beyond RFC 6960 are authorized by their TSL listing alone, with the warning ocsp_responder_not_rfc6960; a listed responder must still carry id-kp-OCSPSigning and be valid now gemSpec_PKI looks a responder certificate up among the TSL's OCSP services and nowhere else (TUC_PKI_006 step 5); the TSL binds a service to no CA (its supply point and extension are placeholders), and RFC 6960 option 2 is a duty of the TSPs for new responder certificates (A_23142-01). TI TSPs sign for their CAs with delegates of other CAs (ehca: a GEM.KOMP-CA51 delegate answers for GEM.SMCB-CA51), so the listing is accepted, but the deviation stays visible as a warning. Requiring validity now of a listed responder, although the TSL's status covers the signers of expired HBA/SMC-B CAs, is the maintainer's decision (TASK-270) gematik aligns responder issuance with RFC 6960 (then the warning never shows)
OCSP CertIDs use SHA-1 (sha1 crate) TI responders reject SHA-256 CertIDs as malformed (RFC 5019 profile). SHA-1 only names the certificate there; integrity rests on the response signature and the SHA-256 certHash TI responders accept SHA-256 CertIDs
ureq's unversioned transport API in ti-client (the Konnektor's transport and the profiles' agent), ureq pinned ~3.4 TlsConfig takes no certificate verifier, and a Konnektor needs Go's: .kon pins accepted by equality, CA chains checked for expectedHost, which is also the SNI (ti-tls's konnektor profile), as the ti and web profiles need theirs. A 60-line connector in ureq's chain does the TLS with that rustls config ureq accepts a custom verifier or rustls config
The web TLS profile accepts an end-entity RSA key of 2048 to 2999 bits with the warning key_phased_out, and a server without a revocation status with revocation_unavailable (spec/pki-validation Deviations, TLSV-041, TLSV-044) gemSpec_Krypt A_28976 asks for RSA ≥ 3000 for internet identities and A_27857-02 for a revocation status; the Web-PKI's CAs issue RSA-2048 by default, Let's Encrypt ended OCSP, and responders fail now and then (idp-ref.app.ti-dienste.de staples nothing and lists no CRL, and GlobalSign's responder failed once). A revoked status still refuses; a verdict without a status is not reused. Maintainer's decision, TASK-282 the services the TI's clients reach staple OCSP or list CRLs and present RSA ≥ 3000 or ECDSA keys
webpki-root-certs licence CDLA-Permissive-2.0 allowed for that crate only a permissive data licence for Mozilla's root certificates, which rustls-platform-verifier bundles for platforms without a usable OS store; only ti-client (and through it ti-cli) depends on it never, unless the licence policy changes

Test PKI

Tests validate certificates generated by OpenSSL, not by ti-pki: an independent implementation produces the bytes, so the encoder and the validator cannot share a mistake. ti-pki/tests/pki/generate.sh (run with just test-pki) writes the topology of gempki's internal/testca plus a few edge cases (a P-521 key, an RSA-PSS chain, a looping and a misnamed cross certificate) as PEM files, with validity fixed around 2026-01-01 so they never go stale. Keys exist only while the script runs. The real TI certificates in ti-pki/tests/fixtures/ and the Wycheproof vectors complement them.

The TSL

The list is authenticated before it is read, by the detached signature file (.sig) and the signer's status file (.ocsp) that the TSL service publishes next to it at the automated download points of gemSpec_TSL A_21182 (spec/tsl-detached). The loaders take the list from there only under the .sha2 of the HTTPS point, and from HTTPS otherwise; a list without a fitting .sig is not used, and a set that does not fit is fetched once more. A .ocsp older than two hours leaves the status to a network query, and --at never uses it. .sig exists only on the plain-HTTP host, so a network that blocks it cannot take new lists; the list in hand stays usable while HTTPS confirms it. ti pki tsl verify TSL SIG --signer-status FILE runs the check on files.

ti_pki::tsl reads gematik's ETSI TS 119 612 list with quick-xml and serde, matching element names without their namespace prefix. It is used as a directory of candidate intermediates, not as a trust source: match_to_roots keeps a CA only if a root of the trust store issued and signed it, and the loading layer stores exactly those (TrustStore::intermediates). What the list also says, and why it is not relied on, is in Known compromises. The production list of September 2026 lists 90 CAs, of which 84 are kept; the six others are self-signed legacy eGK CAs and two expired SMC-B CAs under the retired GEM.RCA1. The tsl example prints this for any environment, and chain --tsl builds chains from it.

OCSP

ti_pki::ocsp builds requests and verifies responses in the core; OcspChecker (feature load) posts them through the same Transport that fetches roots.json and the TSL. The RFC 6960 structures are the crate's own der derives rather than x509-ocsp, whose 0.2 release sits on the previous RustCrypto stack: the signature must be checked over the received bytes anyway, and the dozen structures are smaller than the glue between two der versions. A response is accepted only with a matching CertID, an authorized responder, a certHash over the certificate and within the TUC_PKI_006 window (37.5 s); revocation::apply_revocation holds gempki's table of what each outcome means under HardFail and SoftFail, with a revocation judged at the reference time (TUC_PKI_006 8a and 8b). The test PKI script produces fixed-time responses (good, revoked, unknown and the failure cases) signed by OpenSSL, and tests/fixtures holds two live answers of the reference root responder. tests/real_world.rs checks a reference SMC-B live, by its own responder.

Validation and profiles

Validator is the entry point: built from a TrustConfig and a TrustStore, it validates an end entity through chain building, path validation, the gemSpec_Krypt key check and OCSP for the end entity alone (TUC_PKI_018 step 7). A CA's standing is its status in the verified TSL (step 5, VALID-008): in accord, or revoked after the end entity was issued; under the Kompromissmodell this covers HBA and SMC-B CAs expired since. A profile (ti_pki::profile) fills in a use case's end-entity requirements on top of the type baseline; automatic selection matches a profile's role before the type's default and reports ambiguity instead of guessing. A profile's revocation mode can tighten the configured one but never loosen it, so production stays HardFail even for hci-aut, whose Go default is SoftFail. trustdomain tells production from test certificates offline, for picking a configuration. The validate example strings it all together.

CLI output: lists are tables

ti-cli shows a list of objects as a table: aligned columns under UPPERCASE headings on a terminal, a Markdown table when piped. A single object is label/value fields in sections. Every view is written for its command; a generic JSON-to-text renderer was tried and read worse.

Each table's columns are a decision, not a dump of the JSON:

  • the focus column first: what the reader passes to the next command or looks for;
  • then the columns that tell rows apart, then one or two details;
  • everything else stays in --format json;
  • nothing that does not decide trust: pki tsl show shows only what the CA certificates say, never the TSL's own per-CA metadata (provider names, listed types, policies).
Command Focus Further columns Left to JSON
pki profiles list PROFILE (the --profile value) TYPES, DEFAULT FOR, REVOCATION, ROLES, DESCRIPTION role OIDs
pki roots list ROOT, by generation ORGANIZATION, KEY, VALIDITY, anchor mark subject, SHA-256, PEM, walk order
pki tsl show CA ORGANIZATION (subject O=), ROOT (or the reason there is none), VALIDITY policies, path length, TSL provider, PEM
pki inspect --short SUBJECT (common name) NOT AFTER (date), TELEMATIK-ID (only when a certificate has one; CAs never do) everything else: JSON is the full report
pki roots bundle, pki tsl bundle (with -o) CERTIFICATE NOT AFTER, ALIAS (--p12) SHA-256, PEM
connector config list * for the selected one, NAME (the -c value) URL, CONTEXT path
connector get cards HANDLE (what every card command takes, as is the ICCSN; shortened when longer than 20 characters, whole with -v) TYPE, ICCSN, TERMINAL (CtId and slot), HOLDER versions, insert time
connector get certificates REF KEY, VALIDITY, ALGORITHM; SUBJECT and TELEMATIK-ID only where the certificates differ, otherwise once above the table profession, dates, PEM
connector get services SERVICE VERSION, used mark, ENDPOINT product
connector get identities TELEMATIK-ID TYPE, HOLDER, ICCSN, HANDLE (shortened as in get cards)
connector get expiration VALID UNTIL SUBJECT, ICCSN, SERIAL, HANDLE (shortened as in get cards) terminal
connector get status, errors CONDITION SEVERITY, TYPE, SINCE inactive states
probe SERVICE, status mark HOST, TIME, DETAIL ({kind} … on success, the cause otherwise); Bestandsnetze not routed on one line kind, URL, source, HTTP status, answered

A new list command adds its row here with its choice.

Parity and live tests

tests/openssl_cross.rs runs openssl verify and openssl ocsp next to ti-pki on the same bytes and requires the same verdict: the test PKI's happy and failing chains, the real reference SMC-B chain, every CA of the production TSL fixture, and the OCSP fixtures. It skips when openssl or its brainpool curves are missing. just real-world runs the ignored tests in tests/real_world.rs against gematik's endpoints: roots.json and TSL of every environment through HttpLoader and Reloader, a reference SMC-B end to end with OCSP for the end entity. The deliberate differences to gematik's reference implementation are in gemlibpki-comparison.md.

Gated tests

The suites that need a live system, which just check and CI never run. Before a crate is released, every suite listed for it runs green and the release PR records the runs (environment, Konnektor, card, commit); the rules are in testing.

Suite Run with Needs Proves Required for
ti-pki real world (tests/real_world.rs, #[ignore]) just real-world network, curl roots.json and TSL of every environment through HttpLoader and Reloader, a reference SMC-B end to end with OCSP for the end entity, its responder listed in the TSL ti-pki, ti-cli
ti-pki OpenSSL parity (tests/openssl_cross.rs) just test openssl with brainpool curves the same verdict as openssl verify / openssl ocsp on the same bytes ti-pki
Konnektor end to end (ti-connector-client/tests/e2e.rs) just konnektor-e2e, just konnektor-e2e-interactive (PIN entry, HITL) TI_TEST_KON_PATH (a .kon), optional TI_TEST_PDFA_PATH (interactive), TI_TEST_KON_RECORD_DIR (records the run for tests/recorded.rs) the facades against a real Konnektor and card ti-connector-client, ti-cli
probe through the TI VPN (ti-cli/tests/probe.rs, #[ignore], HITL) TI_TEST_PROBE_ENV=ref TI_TEST_PROBE_VPN=on cargo test -p ti-cli --test probe -- --ignored --exact hitl_the_ti_vpn_block_answers_exactly_with_the_vpn --nocapture, then with TI_TEST_PROBE_VPN=off a Konnektor or TI-Gateway with its TI VPN; network the TI-VPN block of probe answers exactly when the VPN is up, the internet block either way ti-cli
jwz in the browser just jwz-browser (design time, stamped) Chrome for Testing, wasm-bindgen-test-runner jwz and ti-jwz's brainpool in wasm32 in a real browser jwz, ti-jwz (the stamp is checked by just check)

WebAssembly (ti-wasm)

ti-wasm exposes ti-pki's offline and model to JavaScript: verify_tsl gives the TSL view (signature verdict, roots, services with chains, certificate details), describe_certificate what ti pki inspect shows. Its exports are pure; downloads, caching and the clock belong to the caller, since the TI download hosts send no CORS headers and the OCSP responders speak plain HTTP. One module serves Node (initSync with the bytes) and the browser (init with a URL).

#[wasm_bindgen] expands to unsafe glue, so ti-wasm has its own lint table without unsafe_code = "forbid"; its sources are thin wrappers, just wasm-unsafe-free keeps unsafe out of them, and all logic stays in ti-pki under the workspace lints.

The wasm profile is release (opt-level 3) and wasm-build runs wasm-opt -O3: verification speed before size. wasm-build needs wasm-bindgen-cli at the version ti-wasm/Cargo.toml pins (just tools) and binaryen's wasm-opt (brew install binaryen); without it the build works but wasm-vendor refuses the result. A consumer gets the package with just wasm-vendor <dir> into <dir>/vendor/ti-wasm, after the smoke test and the size budget pass, from a clean tree only; VERSION.json names the commit and the module's SHA-256.

Trust material loading

roots.json and the TSL are loaded, cached and hot-reloaded by ti_pki::load (feature load). Loaders are untrusted: whatever the source — HTTP, a mounted file, an offline bundle, a cache — roots.json is verified against the embedded anchor before use, and only the TSL's CAs that a verified root signed are kept. A misbehaving source can therefore only deny service or serve stale data, and the freshness policy catches staleness. The module docs carry the composition and the failure table.

Over HTTP the TSL is not downloaded while the SHA-256 published next to it (.sha2) matches the list in hand; a download must match that hash (A_30044 (2) of C_12791). The hash only saves the download: the list is verified as before, whatever it says.

The core has no HTTP client and no executor: the host implements load::Transport and calls Reloader::tick on its own runtime. just core-deps proves the crate stays free of them, and just wasm32 that the loading layer builds for the browser. A Rust server brings its adapters in its own crate.

Reload strategies

  • Periodic with jitter (default): ReloadPolicy::interval plus a per-process random share of jitter, so a fleet started together does not reload in lockstep.
  • TSL NextUpdate deadline: with honor_tsl_next_update, the next reload is due next_update_lead before the current TSL's NextUpdate if that comes first.
  • External trigger: the host calls Reloader::tick on SIGHUP or from an admin endpoint, e.g. right after a ConfigMap update.
  • Lazy on use — rejected: reloading from snapshot() would put network I/O, verification latency and their failures into the request path, need an executor there, and stampede the origin when many requests notice staleness at once. snapshot() stays a short read that only refuses expired material.

Production caps hard_expiry at MAX_PROD_HARD_EXPIRY (24 h); ReloadPolicy::validate and Reloader::new enforce it.

Kubernetes

  • A ConfigMap volume updates by atomically repointing its ..data symlink. FileTransport reads through the configured path on every call, so it follows the swap; never cache a canonicalised path or an open file.
  • subPath mounts never receive updates; mount the whole volume.
  • Updates arrive after the kubelet sync period plus its cache TTL (about a minute by default), then on the next reload tick — or trigger one.
  • Put roots.json, the TSL and its .sig and .ocsp in one ConfigMap so they change together; a list under the signature of another state does not verify. The producer, ti pki trust export, writes them as one bundle file; consumers keep nothing on disk.
  • Poll, don't watch: inotify is unreliable on these mounts because the files themselves never change, only the symlink.

Offline bundle

For air-gapped deployments, export both artefacts with their metadata on a connected machine (ti pki trust export, or Bundle::new(&config, label, material).write(path) from material any loader produced), transfer the JSON file, and serve it with StaticLoader::from_bundle(Bundle::read(path)?, &config)?, alone or as the backup of a FallbackLoader. The bundle is not signed and needs no signature: it is verified on import like any other source, and its anchor hash rejects a bundle meant for another environment up front. It carries fetched_at, so it ages out by hard_expiry like any material — in production that means a fresh bundle at least daily.

Part B — Tag & reproducible builds

A crate consumed by git tag or from a registry is built outside this workspace, so its in-repo dependencies resolve by the version recorded in its Cargo.toml, not by path. To make a release pick up a sibling's change, that sibling must be released first and the dependent's version requirement raised.

# 1. Land the change on main (PR, review, merge)

# 2. Bump the changed crates: `version` in <crate>/Cargo.toml, its line in
#    [workspace.dependencies] when siblings need the new version, and in ReleaseNotes.md
#    the crate's block under "Unreleased" becomes "## Release <crate> X.Y.Z, <date>"
just changed                       # what changed since each crate's last tag
git add -A && git commit -m "ti-pki: release 0.1.1"

#    A crate with a threshold (mutation-scores.toml: the crates that make a security decision)
#    needs a score that belongs to its code as released and reaches the threshold; `just release`
#    checks it with `just mutants-gate`, which passes a crate without a threshold. Code is what
#    a test compiles or runs: a change to the README, the licence, a document no
#    include_str! compiles in, the stamps, the fuzz targets or the [package] version line
#    needs no new score (`python3 -I scripts/mutation.py affecting <crate> <rev>` lists the rest)
just mutants-record ti-pki         # slow; commit mutation-scores.toml

# 3. Tier 2 must be green before tagging (semver compares against the last tag)
just audit
#    and every gated suite listed for the crates being released (see "Gated tests"),
#    recorded in the release PR

# 4. Tag the crates that changed, then push the tags
just tag ti-pki 0.1.1
just push-tags

Only tag what actually changed — just changed lists crates with commits since their last tag. Unchanged crates keep their existing tag. Check what a release would contain with just package-list <crate>: only the crate's Cargo.toml, README.md, LICENSE and src/ may appear. The release notes of all crates are one file, ReleaseNotes.md.

Reproducible library consumption from another project

Consumption goes through three stages as the distribution matures:

# 1. git, pinned to the crate's own tag (Cargo finds the crate by name in the workspace)
ti-pki = { git = "https://github.com/gematik/zero-lab", tag = "rust/ti-pki/v0.1.0" }
# 2. internal registry (once configured)
ti-pki = { version = "0.1", registry = "gematik" }
# 3. crates.io
ti-pki = "0.1"

A git dependency is exact: the tag names one commit, and the consumer's Cargo.lock records its hash. [workspace.package] sets publish = false for every crate until the registry decision is made; flipping it there opens them all.

release-plz

release-plz.toml is prepared (tag format rust/<crate>/v<ver>) but not yet usable: release-plz derives the next version from the registry, which it skips for publish = false crates, and its git_only mode (as of 0.3.169) fails for a workspace that is not at the repository root. Until a registry is in place, bumps are done by hand as above; afterwards release-plz update does the version and sibling-pin edits (it writes only CHANGELOG.md, so its changelog updates stay off; release notes are ReleaseNotes.md).

Releasing

Crates are released together or alone, each with its own version and tag; ti is the one crate with binaries. A release takes two commands from main:

  1. Gated tests first. Run every suite of "Gated tests" listed for the crates in the release against the real systems and record the results in the release PR; just release cannot do this for you.
  2. just release CRATE=X.Y.Z …, local only, e.g. just release ti-cli=0.4.0 ti-idpd-client=0.2.0 ti-directory-client=0.1.0. It stops unless main is clean and equal to origin/main, each tag is new and above the crate's last rust/<crate>/v*, and ReleaseNotes.md has exactly one ### <crate> block under ## Unreleased for each. Then just check-full and just advisories vet msrv, unless no released crate's code or the workspace's Cargo.toml and Cargo.lock changed since its last tag (a docs-only release, such as a README fix crates.io only shows with a new version): that releases the same code, so only just advisories vet run; the mutation gate and just semver run either way. Per crate the version in its Cargo.toml, the workspace requirement on it in Cargo.toml ("0.2" for 0.x, the major from 1.0) and its block becoming ## Release <crate> X.Y.Z, YYYY-MM-DD; Cargo.lock; just semver for the released crates at their new versions; just jwz-recheck, because Cargo.toml changed (only the browser stamp hashes a manifest, so a bump reruns that one, a few minutes); when ti-cli is in it, just cli-targets builds the three binaries into target/dist with their SHA256SUMS. One commit release: <crate> X.Y.Z, …, one tag rust/<crate>/vX.Y.Z per crate. A failure or Ctrl-C after the gate undoes the bumps.
  3. just publish X.Y.Z [OWNER] (the ti-cli version): git push --atomic origin main with every crate tag of the release commit, gh release create with the binaries, the checksums and that version's notes (plus install and verify lines), then just publish-brew OWNER X.Y.Z. OWNER defaults to TAP_OWNER in the Justfile (gematik). A release without ti-cli is pushed as the recipe prints it.

just audit's semver step compares each tagged crate's public API with its last tag (0.x: a breaking change needs the next minor). It skips ti-cli, a binary whose contract is its JSON schemas, and crates never tagged.

To drop a release that is not published yet: git tag -d <its tags> && git reset --keep HEAD~1 (the recipe prints the line).

publish-brew takes the checksums from the published release, fills ti-cli/Formula/ti.rb with each platform's asset URL and hash, and pushes it to OWNER/homebrew-tap. The formula installs the release binaries; nothing is compiled on the user's machine. The checksums are not signed yet (see "Known compromises").

The golden rule

  • Develop against the workspace — local, fast, no tags, no version edits.
  • Release = land the change → bump version, sibling pin and release notes → just audit → just tag <crate> <version> for each changed crate → just push-tags. Only then do git-tag consumers see the new code, because those builds bypass the workspace paths.

just recipe reference

Recipe Purpose
tools Install the pinned cargo tools (via cargo-binstall when available)
install Install ti into ~/.cargo/bin, built like a release
install-fast Install ti from the local profile (thin LTO, incremental): a change in ti-cli rebuilds in about 20 s instead of about 45 s
check-full Tier 1 before a release (just release runs it): check, test-defaults, features, cli-check
cli-check cli-targets check: clippy with -D warnings for the Linux (musl) and Windows targets of ti in their cross containers; needs Docker
check-changed [base] While working on a branch: the fast checks of the crates it changed against base (default main) and of their dependents; wasm32, nonprod-absent, the manifest checks (unsafe-free, core-deps, jwz-backends, machete, deny, vet) and scripts-test only when their inputs changed
check Tier 1, once per branch before it merges: fmt, clippy, doc, test, wasm32, wasm-unsafe-free, core-deps, nonprod-absent, jwz-backends, jwz-rsa-guard, machete, deny, vet, brainpool-absent for jwz and ti-jwz
wasm32 cargo check for wasm32 of ti-types with std, ti-pki's loading layer, ti-wasm and the jwz crates
wasm-unsafe-free No unsafe in ti-wasm's own sources
check-jwz The jwz crates only (fmt, clippy, doc, test, feature matrix, wasm32, guards): the loop while working on jwz
jwz-backends No openssl, ring, aws-lc or cryptoki under jwz or ti-jwz
jwz-rsa-guard jwz's reserved rsa feature still refuses to build
brainpool-absent <crate> No brainpool in a crate's tree (run for every new component)
verify-formal-jwz Design time (Kani): the bounded proofs in jwz/src/proofs.rs
verify-hax-jwz Design time (hax, F*): extracts jwz's Concat KDF core and proves it against SP 800-56A §5.8.1 (jwz/verification/hax)
jwz-recheck [--all] Reruns the design-time checks (Kani, hax and F*, browser) whose inputs changed and rewrites jwz/verification/stamps; just check fails on a stale stamp without needing the tools
jwz-browser Design time (Chrome for Testing): the browser tests of jwz and ti-jwz's brainpool, wasm32 in headless Chrome
jwz-browser-tools Download Chrome for Testing's headless shell and chromedriver into JWZ_CHROME_DIR (default ~/Development/tools/chrome)
jwz-interop Design time (Go, Python 3): brainpool interop fixtures against Go josebp and Python jwcrypto, both directions, and their coverage table (ti-jwz/interop)
fuzz-jwz [seconds] Design time (nightly, cargo-fuzz): every jwz parser, seconds each (default 30), from the committed seeds
fuzz-ti-cache [seconds] Design time (nightly, cargo-fuzz): ti-cache's Cache-Control parser, seconds (default 30), from the committed seeds
tls-bench Part of check-full: a cold and a cached TLS server verification per profile of spec/pki-validation, release build; fails when a median exceeds its budget (5 ms cold, 10 µs cached)
fuzz-ti-tls [seconds] Design time (nightly, cargo-fuzz): the chain a server presents to ti-tls's web profile, seconds (default 30), from the committed seed
fuzz-ti-pki [seconds] Design time (nightly, cargo-fuzz): ti-pki's parsers of network bytes (the detached TSL signature, the TSL list, OCSP responses), seconds each, from the committed seeds
fuzz-ti-connector-client [seconds] Design time (nightly, cargo-fuzz): the .kon parser, its file check and redacted view, seconds each (default 30)
fuzz-ti-connector-client [seconds] Design time (nightly, cargo-fuzz): the .kon parser with its file check and redacted view, the service directory with its bindings and rewrite, and every Konnektor call on any answer, seeded from the recorded runs, seconds each (default 30)
fuzz-ti-pkcs12 [seconds] Design time (nightly, cargo-fuzz, Go): the PKCS#12 decoder with its password, seeded from a fresh spec/pkcs12 conformance corpus, seconds (default 30)
wasm-build Build the ti-wasm package into target/ti-wasm/pkg
wasm-size Module size raw, gzip, brotli; fails over 2.0 MB raw or 700 KB gzip
wasm-smoke The package in Node on the real TSLs, cross-checked with ti pki tsl verify
wasm-vendor [<dir>] Copy the package into <dir>/vendor/ti-wasm (default ../../gemiverse)
core-deps Prove ti-pki's core pulls in no HTTP client, executor or file watcher
test-pki Regenerate the OpenSSL test PKI in ti-pki/tests/pki
nonprod-absent Prove ti-pki without dangerous-nonprod contains no non-prod trust material
audit Tier 2: advisories, vet, msrv, semver
vet-suggest Dependencies still awaiting a cargo vet audit
miri, mutants <crate> [jobs] On demand: Miri (nightly), mutation testing (jobs as for mutants-record)
mutants-record <crate> [jobs] Measure a crate's mutation score on the committed tree into mutation-scores.toml; jobs mutants at once (default 4), each in a copy of rust/ beside a link to spec/; 1 mutates the tree in place
mutants-gate <crate>… The recorded scores of crates about to be released that have a threshold: present, current, at least the threshold; a crate without one is not gated
coverage [<crate>…] Line coverage per crate (HTML in target/coverage/html) and the cited requirements whose code no test runs
versions Show the latest tag for every crate
changed List crates with commits since their last tag
tag <crate> <ver> Create the rust/<crate>/v<ver> release tag
push-tags Push all local tags to origin
package-list <crate> Show the files a release of the crate would contain
release <crate=ver>… Prepare a release of one or more crates locally (see "Releasing")
release-notes [<ver> [<crate>]] A crate's notes from ReleaseNotes.md (default: ti-cli's pending ones)
publish <ver> [<tap owner>] Push and publish a prepared ti release
publish-brew <owner> <ver> Point the formula in <owner>/homebrew-tap at a ti release