This document describes Cryptad's local signed staged app bundle workflow for first-party AppHost apps.
This page covers the local signed bundle sidecars and Gradle tasks used to stage, sign, and verify first-party AppHost apps. PR-194 treats staging, signing, and verification as Phase 3 release gates; see phase-3-platform-primacy-closeout.md and cryptad-release-workflow-and-runbook.md.
Related but documented elsewhere:
- Developer CLI workflow for standalone staged bundles: app-dev-cli.md
- Signed catalog sources and remote/local catalog artifact install/update: app-catalogs.md
- Production first-party catalog channel semantics: production-first-party-catalog-channels.md
- App-owned static UI routing for installed bundles: app-owned-ui.md
- App UI design-system assets and linting: app-ui-design-system.md
- Browser SDK helpers for app-owned static UI: platform-sdk-js.md
- Public catalog-over-Crypta source syntax and trust boundaries: app-catalogs.md
- App update candidate detection, policy, staged apply, and rollback scope: app-update-lifecycle.md
- Signed app-data migration declarations for schema-changing updates: app-upgrade-data-migrations.md
- Browser-side bundle uploads, Crypta app-artifact fetching, remote screenshot proxying, and additional sandbox controls beyond the current Linux bubblewrap provider remain future platform work.
Signed bundles remain channel-neutral artifacts. Release jobs choose the catalog channel when they
write the signed cryptad-app-catalog.properties entry. Schema v3 catalog entries can declare
channel=stable|beta|nightly|deprecated, support.status, deprecation.status, optional
replacementAppId, optional security advisory references, and minimumCryptaVersion /
maximumCryptaVersion. These fields are signed catalog metadata: they do not weaken bundle digest
or bundle signature verification.
The production-safe default is stable. Beta and nightly catalog entries are preview channels that
operators must explicitly select in Web Shell browsing or app-update policy before automation can
process them. Deprecated entries should carry replacement/deprecation metadata and are not ordinary
automatic update candidates.
stageApp still produces an unsigned staged bundle by default. Signing adds two UTF-8 sidecars at the bundle root:
cryptad-app.properties
bin/<launcher>.sh
static/...
static/crypta-ui/...
cryptad-app.digests
cryptad-app.signature
The sidecars follow the PR-192 local distribution format:
cryptad-app.digestsdigest.version=1digest.algorithm=SHA-256file.<N>.path=<normalized relative path>file.<N>.sha256=<lowercase hex sha256>
cryptad-app.signaturesignature.version=1signature.algorithm=Ed25519signature.key.id=<stable key id>signature.payload=cryptad-app.digestssignature.value.base64=<base64 Ed25519 signature over the exact digest sidecar bytes>
The digest includes cryptad-app.properties and regular bundle files. It excludes reserved
distribution sidecars such as cryptad-app.digests, cryptad-app.signature,
cryptad-app.catalog, and cryptad-app.catalog.signature.
The packager rejects archive metadata that can appear in local working trees, including
AppleDouble ._* files, __MACOSX/ directories, and .DS_Store. Remove those files before
signing or packaging a bundle.
App bundles can declare browser UI ownership with app.ui.mode and app.ui.entry.
app.ui.mode=none|shell-panel|static
app.ui.entry=static/index.htmlThe mode is optional for compatibility. Missing app.ui.entry means none; an absolute local
entry such as /app/node/#queue infers shell-panel; a relative entry such as
static/index.html infers static. Static entries are normalized relative paths inside the
signed bundle and are validated during structure checks. They must not point at reserved
distribution sidecars, absolute paths, traversal segments, Windows drive prefixes, empty segments,
colons, or control characters. Existing shell-panel entries remain valid.
The repo-owned Queue Manager, legacy Publisher, Site Publisher, Profile Publisher, Social Inbox RC,
Feed Reader, and Trust Graph Local RC bundles use app.ui.mode=static and
app.ui.entry=static/index.html, so installed copies open through isolated app origins when
available. Queue Manager and legacy Publisher remain compatibility fallbacks for the current
retirement map. Site Publisher is the content reference app for local publishing workflows, Profile
Publisher is the identity-profile reference app for vault-backed profile-document publishing,
Social Inbox RC is the threaded social inbox reference app for local threads, read state,
subscriptions, channel/search filters, safe author links, and advisory Trust Graph annotations.
Feed Reader is the bounded content-subscription reference app for durable USK feed follow behavior,
feed reading, and generated feed publication, and Trust Graph Local RC is the local trust-service
reference app for trust statements, anchors, preview scoring, and the trust.score app-service
provider.
See app-owned-ui.md for the /apps/{appId}/ route contract, first-party
bootstrap JSON, static asset security boundary, and API summary fields. See
platform-sdk-js.md for the staged browser SDK used by first-party static UI
bundles. Static bundles should also include the canonical design-system assets under
static/crypta-ui/, load crypta-ui-tokens.css and crypta-ui.css before app-specific CSS, and
pass crypta-app ui lint before signing or cataloging. See
app-ui-design-system.md.
App bundles can declare the requested AppHost sandbox mode:
sandbox.mode=none|restricted-process|wasm-preview
sandbox.required=falseBoth fields are optional. Missing sandbox.mode defaults to none, and missing
sandbox.required defaults to false. Unknown modes and malformed booleans fail manifest
validation before a bundle is installed or launched.
The restricted-process mode maps to AppHost's restricted-process provider selection. On Linux
hosts with bubblewrap available, AppHost can launch through the bubblewrap provider and report
supportLevel=enforced. On unsupported hosts, optional restricted-process apps fall back to
best-effort launch hygiene with sanitized environment, explicit working directory, and app-scoped
mutable directories. sandbox.required=true now requires an enforced restricted-process provider;
best-effort support is not enough. The wasm-preview mode is reserved for a future provider and is
unsupported by the default runtime.
App bundles can declare data and cache quota metadata:
quota.data.bytes=0
quota.cache.bytes=0Both fields are optional non-negative byte counts. AppHost enforces a data or cache quota only when
the value is positive. A missing field and an explicit 0 both mean unlimited or no explicit app
quota. This preserves compatibility with the first-party Queue Manager, Publisher, and Site
Publisher staged manifests, which currently declare quota.data.bytes=0 and
quota.cache.bytes=0.
Quota enforcement is scoped to AppHost-managed app data and cache directories. It does not apply to the immutable installed bundle, catalog scratch space, daemon datastore files, or arbitrary host paths. AppHost measures regular files without following symlinks and reports path-free warnings when a scan is incomplete. If a positive quota is active for that area, incomplete measurement blocks launch and automatic restart because AppHost cannot enforce the quota from a partial byte count. Runtime status and app summaries include measured data/cache usage, effective limits, over-limit flags, process-log size/limit metadata, and warning text that is safe for operator display.
Process-log size is controlled by host policy rather than signed manifest metadata. AppHost keeps
process.log bounded on a best-effort basis at lifecycle and status checkpoints while preserving
the tail of the file for diagnostics. AppHost may retain a small additional redaction overlap beyond
the displayed process-log limit so bounded log reads can still redact tokens and known AppHost paths
when the display tail begins inside a sensitive value.
Schema-changing app updates can declare a signed app-data migration contract:
app.data.schema.current=2
app.data.schema.namespaces=ui-state
app.data.schema.namespace.ui-state.current=2
app.data.migrations=ui-state-v1-v2
app.data.migration.ui-state-v1-v2.namespace=ui-state
app.data.migration.ui-state-v1-v2.from=1
app.data.migration.ui-state-v1-v2.to=2
app.data.migration.ui-state-v1-v2.command=bin/migrate-feed-data.sh
app.data.migration.ui-state-v1-v2.rollbackCompatible=false
app.data.migration.ui-state-v1-v2.requiresStopped=true
app.data.migration.ui-state-v1-v2.description=Upgrade UI state to schema v2.These fields are normalized and validated with the manifest before the bundle is signed or installed. Migration command values are relative bundle paths, not shell strings. Absolute paths, traversal segments, Windows drive prefixes, empty segments, control characters, and unsafe namespace or step identifiers are rejected. Bundle validation checks that the resolved command is a regular safe file inside the signed bundle, but it does not depend on host executable-bit behavior. When a migration is actually required, the migration runner enforces direct launchability and fails closed before bundle replacement if the local host cannot execute the entrypoint without a shell.
During update staging/apply the Platform API exposes only path-free migration summaries. It does not expose the command path, staged bundle path, raw command output, tokens, private insert URIs, or raw app-data values. See app-upgrade-data-migrations.md for the lifecycle, rollback snapshot, and PR-250 non-goals.
App bundles can declare a minimal restart policy:
app.restart.policy=never|on-failure
app.restart.maxAttempts=0
app.restart.backoff.ms=0All fields are optional. The default policy is never, with no automatic restart attempts. When a
bundle opts in to on-failure, AppHost restarts only after a non-zero process exit, only within the
current daemon session, and only up to app.restart.maxAttempts. Explicit operator stop suppresses
automatic restart. Each restart receives a fresh CRYPTAD_APP_TOKEN.
app.restart.maxAttempts and app.restart.backoff.ms must be non-negative integers. AppHost does
not persist restart state across daemon restarts and does not run app-provided health checks.
See apphost-runtime-hardening.md for the process boundary, runtime status, process-log tailing, token redaction, and remaining sandbox limitations.
crypta-app is the developer-facing CLI for standalone AppHost bundles. It is delivered by the
:platform-devtools application plugin and can be installed locally with:
./gradlew :platform-devtools:installDistUse it when an app should be scaffolded, signed, packed, or cataloged outside the first-party
apps/* Gradle projects:
crypta-app init \
--dir build/dev-apps/hello-queue \
--app-id hello-queue \
--name "Hello Queue" \
--version 0.1.0 \
--ui-mode static \
--permission queue.read
crypta-app ui lint --bundle-dir build/dev-apps/hello-queue --strict
crypta-app validate --bundle-dir build/dev-apps/hello-queue --strict
crypta-app sign \
--bundle-dir build/dev-apps/hello-queue \
--key-id dev-local \
--private-key-file dev/app-signing-private.pem
crypta-app pack \
--bundle-dir build/dev-apps/hello-queue \
--output dist/apps/hello-queue-0.1.0.zip \
--overwrite
crypta-app verify \
--bundle-dir build/dev-apps/hello-queue \
--trusted-key-id dev-local \
--trusted-public-key-file dev/app-signing-public.pemcrypta-app init creates a standalone staged bundle directory, not a new Gradle subproject. The
static template copies or vendors the browser SDK as static/crypta-platform.js when that resource
is available, and copies canonical design-system assets into static/crypta-ui/. See
app-dev-cli.md for the full scaffold, lint, pack, and catalog flow.
The CLI does not replace the first-party Gradle workflow. Queue Manager, legacy Publisher, Site
Publisher, Profile Publisher, Social Inbox RC, Feed Reader, and Trust Graph Local RC can keep
using their stageApp, signApp, and verifyApp tasks.
Signed bundle manifests remain the source for installed app id, version, launch settings, UI entry, sandbox mode, quota metadata, and declared permissions. Signed catalogs can add optional store metadata around those bundles so the Web Shell can show a richer install/update review surface.
Catalog entry descriptors accepted by crypta-app catalog create can include homepage,
source, license, categories, minimumCryptaVersion, review.status, review.note,
permissions.rationale.<permission>, screenshot.N, changelog.summary, changelog.uri, and
api.* compatibility metadata. The generated catalog writes those fields under app.<id>.* and
uses catalog.version=2. Descriptor/artifact pairs without store metadata or API compatibility
metadata continue to generate minimal catalog.version=1 catalogs.
These fields are display and review metadata. The catalog signature still authenticates the exact catalog bytes, and the app bundle signature still authenticates the bundle payload. Review notes, permission rationales, screenshot URLs, changelog URLs, and compatibility hints do not grant trust or bypass AppHost verification.
Per app:
:apps:queue-manager:stageApp:apps:queue-manager:signApp:apps:queue-manager:verifyApp:apps:publisher:stageApp:apps:publisher:signApp:apps:publisher:verifyApp:apps:site-publisher:stageApp:apps:site-publisher:signApp:apps:site-publisher:verifyApp:apps:profile-publisher:stageApp:apps:profile-publisher:signApp:apps:profile-publisher:verifyApp:apps:social-inbox:stageApp:apps:social-inbox:signApp:apps:social-inbox:verifyApp:apps:feed-reader:stageApp:apps:feed-reader:signApp:apps:feed-reader:verifyApp:apps:trust-graph:stageApp:apps:trust-graph:signApp:apps:trust-graph:verifyApp
Root convenience tasks:
stageFirstPartyAppssignFirstPartyAppsverifyFirstPartyApps
stageApp remains unsigned on purpose. Use signApp when you need a signed local bundle, and verifyApp to re-digest and verify a signed staged bundle with the configured public key.
Use Gradle properties or environment variables. The same inputs work for both first-party app projects and the root convenience tasks.
| Purpose | Gradle property | Environment variable | Notes |
|---|---|---|---|
| Stable signing key id | cryptadAppSigningKeyId |
CRYPTAD_APP_SIGNING_KEY_ID |
Required for signApp and verifyApp. |
| Private key bytes | cryptadAppSigningPrivateKeyBase64 |
CRYPTAD_APP_SIGNING_PRIVATE_KEY_BASE64 |
Required for signApp. Base64-encoded PKCS#8 Ed25519 private key bytes. |
| Private key file | cryptadAppSigningPrivateKeyFile |
CRYPTAD_APP_SIGNING_PRIVATE_KEY_FILE |
Alternative to *PrivateKeyBase64. File may contain PEM, Base64 text, or raw DER bytes. |
| Public key bytes | cryptadAppSigningPublicKeyBase64 |
CRYPTAD_APP_SIGNING_PUBLIC_KEY_BASE64 |
Required for verifyApp. Base64-encoded X.509 Ed25519 public key bytes. |
| Public key file | cryptadAppSigningPublicKeyFile |
CRYPTAD_APP_SIGNING_PUBLIC_KEY_FILE |
Alternative to *PublicKeyBase64. File may contain PEM, Base64 text, or raw DER bytes. |
For private keys, prefer CRYPTAD_APP_SIGNING_PRIVATE_KEY_BASE64 or
cryptadAppSigningPrivateKeyFile. Avoid putting base64 private key material directly on the shell
command line with -PcryptadAppSigningPrivateKeyBase64=....
Production-facing AppHost installs and updates reject unsigned bundles by default. To install a signed staged bundle through the live node, start the node with either direct trusted-key inputs or a trusted-keys properties file.
| Purpose | System property | Environment variable | Notes |
|---|---|---|---|
| Trusted key id | cryptad.apphost.trustedKeyId |
CRYPTAD_APPHOST_TRUSTED_KEY_ID |
Pairs with a direct trusted public key. |
| Trusted public key bytes | cryptad.apphost.trustedPublicKeyBase64 |
CRYPTAD_APPHOST_TRUSTED_PUBLIC_KEY_BASE64 |
Base64-encoded X.509 Ed25519 public key bytes. |
| Trusted public key file | cryptad.apphost.trustedPublicKeyFile |
CRYPTAD_APPHOST_TRUSTED_PUBLIC_KEY_FILE |
File may contain PEM, Base64 text, or raw DER bytes. |
| Trusted keys file | cryptad.apphost.trustedKeysFile |
CRYPTAD_APPHOST_TRUSTED_KEYS_FILE |
Properties file with trusted.keys.version=1 plus key.<n>.id, key.<n>.algorithm, and key.<n>.public.key.base64. |
| Development-only unsigned installs | cryptad.apphost.allowUnsigned=true |
CRYPTAD_APPHOST_ALLOW_UNSIGNED=true |
Explicit escape hatch for local development and tests. Do not enable in production. |
Example trusted-keys file:
trusted.keys.version=1
key.0.id=dev-local
key.0.algorithm=Ed25519
key.0.public.key.base64=<base64-x509-public-key>
Stage an unsigned bundle:
./gradlew :apps:queue-manager:stageAppStage the Site Publisher reference app:
./gradlew :apps:site-publisher:stageAppStage the Profile Publisher reference app:
./gradlew :apps:profile-publisher:stageAppStage the Feed Reader reference app:
./gradlew :apps:feed-reader:stageAppStage the Social Inbox RC reference app:
./gradlew :apps:social-inbox:stageAppStage the Trust Graph Local RC reference app:
./gradlew :apps:trust-graph:stageAppSign it with a local development key pair:
./gradlew :apps:queue-manager:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemSign Site Publisher with the same local development key pair:
./gradlew :apps:site-publisher:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemSign Profile Publisher with the same local development key pair:
./gradlew :apps:profile-publisher:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemSign Feed Reader with the same local development key pair:
./gradlew :apps:feed-reader:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemSign Social Inbox RC with the same local development key pair:
./gradlew :apps:social-inbox:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemSign Trust Graph Local RC with the same local development key pair:
./gradlew :apps:trust-graph:signApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pemVerify it with the matching public key:
./gradlew :apps:queue-manager:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemVerify Site Publisher with the matching public key:
./gradlew :apps:site-publisher:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemVerify Profile Publisher with the matching public key:
./gradlew :apps:profile-publisher:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemVerify Feed Reader with the matching public key:
./gradlew :apps:feed-reader:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemVerify Social Inbox RC with the matching public key:
./gradlew :apps:social-inbox:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemVerify Trust Graph Local RC with the matching public key:
./gradlew :apps:trust-graph:verifyApp \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemStage, sign, and verify all first-party apps:
The root first-party tasks include Queue Manager, Publisher, Site Publisher, Profile Publisher, Social Inbox RC, Feed Reader, and Trust Graph Local RC.
./gradlew \
stageFirstPartyApps \
signFirstPartyApps \
verifyFirstPartyApps \
-PcryptadAppSigningKeyId=dev-local \
-PcryptadAppSigningPrivateKeyFile=dev/app-signing-private.pem \
-PcryptadAppSigningPublicKeyFile=dev/app-signing-public.pemTo install the signed bundle through the running node, export the matching trusted public key before starting Cryptad:
export CRYPTAD_APPHOST_TRUSTED_KEY_ID=dev-local
export CRYPTAD_APPHOST_TRUSTED_PUBLIC_KEY_FILE=dev/app-signing-public.pemIf you deliberately want to test unsigned local bundles against a live node, opt in explicitly:
export CRYPTAD_APPHOST_ALLOW_UNSIGNED=trueDo not commit production private keys, keystores, exported PKCS#8 private keys, or long-lived test secrets to this repository.
Preferred local patterns:
- Keep signing keys outside the repo and pass file paths with
-P...File=.... - Keep trusted public keys outside the repo and pass them through environment variables or a local trusted-keys file outside the checkout.
- Use environment variables for short-lived automation when file-based secrets are not practical.
- Label local development keys clearly as non-production.
Public catalog governance, silent third-party auto-update policy, Crypta app-artifact fetching, remote screenshot proxying, browser-side bundle uploads, and sandbox controls beyond the current Linux bubblewrap provider remain later platform work. Signed bundles now carry the manifest metadata, API compatibility hints, sandbox/quota declarations, and staged browser assets needed by catalog review, app-owned static UI routes, and the manual app-update lifecycle.