Skip to main content
You can run your own VisualSign parser as a Turnkey Verifiable Cloud (TVC) app. You own the app and the operator keys that approve its deployments, and you pick the parser image and its configuration. Turnkey runs the AWS Nitro Enclave and the QuorumOS (QOS) runtime underneath it. The binary to deploy is parser_http_server. It serves the same Turnkey HTTP+JSON envelope as the hosted service. Each successful response carries a bootProof with a real NSM attestation document, so any client can verify the response without trusting you, Turnkey’s API, or the network in between.
parser_http_server reads the manifest and ephemeral key that QOS provisions (/qos.manifest, /qos.ephemeral.key), so it runs as a QOS pivot. It does not run on a bare Nitro Enclave without QOS.

Prerequisites

  • A Turnkey organization, with the tvc CLI logged in (tvc login).
  • An operator key held locally (~/.config/turnkey/orgs/<org-alias>/operator.json, where <org-alias> is the org name tvc login saved). That key is what approves your deployments.
  • Docker, to pull the image and hash the binary inside it.
  • tvc-deploy from this repo. It isn’t installed on your PATH: wherever this page runs tvc-deploy, run cargo run --manifest-path tools/tvc-deploy/Cargo.toml -- from a checkout of this repo instead.
  • visualsign-turnkeyclient, to verify the deployment.
  • A Turnkey API key for visualsign-turnkeyclient, stored as ~/.config/turnkey/keys/<api-key-name>.public and <api-key-name>.private (the Turnkey CLI’s key format). tvc login doesn’t create these files, and visualsign-turnkeyclient refuses to run without them, even when your parse routes are open. Your organization ID is the id under your org in ~/.config/turnkey/tvc.config.toml.

1. Pick and pin the image

Images are published to ghcr.io/anchorageoss/parser_http_server. Always reference one by tag and digest: a tag alone is mutable, and a bare digest is shown back as :latest.
To deploy a parser you built yourself, publish it from your fork first and use your own image here. See Deploying from a fork. Hash the pivot binary yourself. This is the value QOS measures, and the value a verifier will later find in the attested manifest:

2. Create the app

Generate a template with tvc app init -o app.json and set name and manifestSetParams. Make sure the manifest set contains only operators whose keys you hold. If it doesn’t, nobody can approve a deployment and the app is stranded. The template’s newOperators already holds the public key of your local operator.json, so for a first app you can keep it. To reuse an existing operator instead, list it by ID and clear newOperators:
Leave dangerousEnableDebugModeDeployments false. Debug mode zeroes the PCRs, so an attesting parser can never go healthy in it.
--no-operator-reuse stops tvc from substituting the operator from your last app create (without it, tvc prints Reusing operator ... and uses that one). Check that the printed Manifest Set Operator IDs are the ones you expect. That ID is the <your-operator-id> you pass to deploy-pivot below, and App ID is the <app-id>.

3. Deploy

deploy-pivot re-derives the binary digest from the image and refuses to continue on a mismatch. It then creates the deployment, approves it with your operator key, waits until it’s healthy, and sets it live. Everything after -- becomes the deployment’s pivotArgs, and those are part of the manifest the attestation commits to: TVC has no environment-variable injection: pivotArgs is the parser’s entire configuration, and anyone verifying the deployment can read it from the attested manifest. With --boot-proof-source nsm, GET /health returns 503 until the parser holds a verified, unexpired attestation. A replica whose NSM attestation fails never goes healthy, so “healthy” means each replica has attested successfully. deploy-pivot approves with tvc deploy approve --dangerous-skip-interactive, so it doesn’t show you the manifest before signing. Step 4 is where you check what you approved. The app is served at https://<publicDomain>, where publicDomain comes from tvc app list --message-format json. For new apps it’s app-<app-id>.app.turnkey.cloud, but older apps use app-<app-id>.turnkey.cloud, so read it rather than constructing it. The routes are GET /health, POST /visualsign/api/v1/parse and POST /visualsign/api/v2/parse. The parse routes take the Turnkey JSON envelope, not the gRPC messages in the API reference:
A curl response proves nothing on its own; verify it as described in the next step.

4. Verify the deployment end to end

visualsign-turnkeyclient checks everything a wallet should check, in one command. Pin the PCRs (see Expected values below), and save the manifest so you can inspect it:
--key-name stamps the request. The parser only checks the stamp if you set --allowed-stamp-pubkeys-hex, in which case the key must be on the list; the client needs the key files either way.
Always pass --pcrs. Without it, verify still exits 0 and prints PCRs verified: 32 PCRs found, but it hasn’t compared them to anything. Take the PCR values from an independent source (see Expected values), never from verify’s own output or from the manifest’s Enclave section: both come from the server you’re checking.
A pass means all of the following held:
  1. inputPayloadDigest is the SHA-256 of the payload you sent, and the response’s signed message is the hash of exactly the signablePayload, inputPayloadDigest and metadataDigest it returned, so the payload can’t be swapped in transit.
  2. The attestation document (bootProof.awsAttestationDocB64) verifies against the AWS Nitro root CA, and PCR0-3 match your pinned values.
  3. The attestation’s public_key is the key that signed the response.
  4. The manifest from bootProof hashes to the attestation’s user_data. If a response carries no manifest at all, verify skips this check, which is why later runs should pin --qos-manifest-hex.
  5. The response signature verifies.
Then confirm the manifest describes the deployment you intended:
Check that:
  • Pivot Config / Binary Hash equals the $PIVOT_DIGEST you computed in step 1. This ties the attestation to the exact binary.
  • Args are exactly the pivotArgs you deployed.
  • DebugMode is false.
  • Manifest Set lists only your operators. The human-readable output shows only the threshold and member count, so add --json and compare each manifest.manifestSet.members[].pubKey with the public_key in your operator.json.
  • Hashes / Manifest is the manifest hash. Record it: later runs can pin it with verify --qos-manifest-hex <hash>, which fails on any change to the deployment.
Don’t use verify’s JSON output for the binary check: its pivotBinaryHash field holds the manifest hash (user_data), not the binary’s sha256. For the same reason, verify --pivot-binary-hash-hex is compared with user_data, so passing it your $PIVOT_DIGEST fails.

Expected values

For how each check works and how to implement them in your own verifier, see Attestation Verification.

Updating

Every deploy-pivot creates a new deployment with a new manifest, so the manifest hash changes. The binary hash and PCRs change only if you change the image or TVC changes the QOS runtime. Re-run step 4 after each deploy and update the values you pin.

Deploying from a fork

The images under ghcr.io/anchorageoss are built and pushed by this repo’s stagex workflow. To deploy a parser you changed, run the same workflow in your fork. It pushes to your own namespace, ghcr.io/<owner>/<target>, and you deploy that image with steps 1 to 4 above.

Why a pull request can’t publish your image

Workflows for a pull request from a fork run in this repository, with a read-only GITHUB_TOKEN and none of its secrets. That stops code in a pull request from writing to this repository’s packages or reading its credentials. Those runs still build every image, but they skip the registry login and push. To get a published image, run the workflow in your fork, where it uses your fork’s own token.

Publish from your fork

  1. In your fork, open the Actions tab and enable workflows. GitHub disables them on new forks.
  2. Start a stagex run in one of these ways:
    • Run it manually: Actions → stagex → Run workflow, or gh workflow run stagex.yml --repo <owner>/visualsign-parser --ref <branch-or-tag>.
    • Push a v*.*.* tag.
    • Push to main with changes under src/, images/, scripts/, .github/ or the Makefile.
  3. Find the images at ghcr.io/<owner>/parser_app, parser_cli, parser_gateway and parser_http_server. <owner> is your user or organization name in lowercase, since ghcr.io only accepts lowercase paths. Every build is tagged with its full commit SHA. Manual runs and tag pushes are also tagged with the branch or tag name (a character a registry tag can’t hold, such as the / in feature/x, becomes -), and pushes to main with latest, main and v<semver>. A manual run on main gets latest and main too, but not v<semver>.
  4. New packages on ghcr.io are private. TVC has to pull the image, so make each package you deploy public: on the package’s page, open Package settings and change its visibility. Only ghcr.io/anchorageoss/* images are official. A public ghcr.io/<owner>/... package is yours and carries no endorsement from this project. If your TVC setup can pull a private image with credentials, that is the alternative to making it public; this page hasn’t confirmed that TVC supports it.
Each image’s org.opencontainers.image.source label names the repository that built it. Treat it as a label, not proof: an image is identified by its registry path and digest. You don’t need to add secrets or change settings. The job requests packages: write itself and pushes with your fork’s GITHUB_TOKEN. The DOCKERHUB_API_KEY secret is optional. It only logs in to Docker Hub to avoid pull rate limits on the stagex base images, and the login is skipped when the secret isn’t set. Leave it unset in your fork: the login uses this repo’s Docker Hub username, so another account’s key fails. Pushing to main in your fork also runs the release workflow. It tags a version, creates a GitHub release in your fork and starts stagex for that tag. Disable the release workflow in your fork if you don’t want that.

Deploying your image to TVC

The TVC Deploy workflow only runs in anchorageoss/visualsign-parser, because it needs that repository’s Turnkey credentials. Deploy your image from your own machine with tvc-deploy, against your own Turnkey organization, as in steps 1 to 4: set IMAGE to your ghcr.io/<owner>/parser_http_server:<tag>. tvc-deploy authenticates with your tvc login configuration, or with TVC_ORG_ID, TVC_API_KEY_PUBLIC and TVC_API_KEY_PRIVATE set in the environment. For parser_app and parser_http_server, each stagex run’s summary lists the pinned image URL and the expected pivot digest for your image. On a tagged build with a matching release in your fork, the run also adds them to that release’s notes.

Verifying an image someone else built

Before you deploy an image you didn’t build, such as one from a contributor’s fork, rebuild it rather than trusting it. The images are built with StageX, so the same commit and build inputs produce the same binary. A rebuild that matches proves the binary was built from that source. It does not prove the source is safe, so read the source before you deploy it. The rebuild also runs the fork’s build files on your machine: its Makefile (including any $(shell ...) call) and its Containerfiles. Review them first, or do the rebuild in a disposable VM. Every stagex run’s summary lists, for each target, the Commit, the VERSION, the SOURCE_URL and the OCI manifest digest it built. VERSION is compiled into the binary and SOURCE_URL is recorded in the image’s source label, so pass the same values when you rebuild. The run summary comes from the fork, so treat each value as untrusted input. Save these steps as one script, which reads the values as data, checks them, shows you the fork’s changes, and builds only after you confirm. Paste each value at its prompt; don’t edit them into the script, where a quote or ; in a value would run as shell code. The build needs Docker with the containerd image store enabled and a docker-container buildx builder, as in CI (create it once with docker buildx create --driver docker-container --name build --use):
The checks match the shapes the workflow accepts; it refuses to build with any other VERSION. They matter because the Makefile passes both values to the shell, so a value with a quote or a ; in it would run commands on your machine. A manifest digest mismatch is not conclusive. This reads the first entry of index.json, which assumes it has a single entry, and this page hasn’t confirmed that it equals the registry digest after docker load and push. The pivot hash below is what decides. Runs started by a pull request build a temporary merge commit that isn’t in the fork. Their summary lists the PR head and base commits instead, and they can’t be reproduced exactly. Verify a manual, tag or main run. Then compare the pivot binary from your build with the one in the published image. This is the hash TVC measures, so it’s the check that matters:
If the hashes match, deploy with the image pinned by digest (IMAGE_URL) and the PIVOT_DIGEST from your own build, as in step 3. deploy-pivot re-derives the digest from the image it pulls and refuses a mismatch, and the attested manifest records the hash you approved.

Next steps