pairputer is a technical demo that people deploy into their own AWS accounts. Because the default deploy pulls container images that the project publishes, rather than images you build yourself, supply-chain integrity is the trust boundary that matters most. This document describes every artifact a deploy trusts and how you verify each one, then covers the runtime authentication model.
The guiding principle is provable in your own account, not "trust the project." You can independently verify the published images offline, against a Sigstore root committed in this repo, before you deploy.
pairputer is a demo, not a supported product. There is no security SLA, no coordinated-disclosure timeline, and no bug-bounty program. If you find a security problem, open a GitHub issue. Describe the problem and how to reproduce it. For a sensitive report, open a minimal issue asking for a private channel rather than posting details publicly.
These choices are fixed:
- Keyless signing with cosign (Sigstore + OIDC). There is no private signing key to protect. Provenance is bound to the CI identity that built the image.
- Digest-pin everything. One
@sha256:digest threads through sign, publish, and deploy. Tags never appear on the trust path. In your stack, the digest pin is the deploy-time integrity guarantee: a@sha256:reference is immutable and content-addressed, so the template cannot name one image and receive another. - Verify out of band; don't force an in-stack gate.
scripts/verify-images.shlets you, your security team, or an auditor confirm offline that the pinned digests are cosign-signed by this repo's CI and carry a SLSA provenance attestation. The 1-click stack does not force a per-deploy verification gate, because that gate would run in your account (your cost, your CodeBuild or Lambda, your Sigstore egress, your failure surface) for a guarantee the digest pin already provides. See Verify the images for why, and for the opt-in path if you want in-stack enforcement. - Build and sign public images only in CI. GitHub Actions builds and signs the public images through GitHub OIDC, never from a laptop. Their provenance is "built by this repo's CI," not "built by a person."
The ImageSource parameter is the first field in the launch form. It selects where the MCP server and
relay container images come from. The default is Public.
Public mode uses the project's signed, digest-pinned images from public.ecr.aws. It takes no inputs
and builds nothing.
The native AWS::BedrockAgentCore::Runtime CloudFormation resource rejects public.ecr.aws URIs in its
ContainerUri schema, because that schema only matches private ECR. Public mode therefore creates the
AgentCore runtime through a small API-backed custom resource (Custom::PairputerAgentCoreRuntime); the
AgentCore API accepts public ECR even though the CloudFormation resource does not. The custom-resource
Lambda is least-privilege: it holds bedrock-agentcore:{Create,Update,Delete,Get,List}AgentRuntime,
iam:PassRole scoped to the single controller role (iam:PassedToService: bedrock-agentcore), and log
permissions.
Private mode uses private ECR in your own account. You have two options:
- Bring your own images. Set
PrivateMcpContainerUriandPrivateRelayContainerUri. These images are then your own supply-chain responsibility. - Let the stack copy the project's images. Leave either URI blank, and an in-stack CodeBuild job
(
ImageCopyStack) cosign-verifies the project's signed public digest first (offline, against the pinned Sigstore root) and only then usescraneto copy it into a private ECR repo in your account.
Private mode uses the native AWS::BedrockAgentCore::Runtime resource, which accepts private ECR.
Every artifact and its integrity mechanism:
| Artifact | Origin | Integrity mechanism |
|---|---|---|
| CloudFormation templates | Project launch S3 bucket | Bucket policy allows reads only through CloudFormation (aws:CalledVia). Templates carry no secrets; every secret is generated in your account at create time. |
| MCP server image | GitHub Actions CI | Digest-pinned (immutable, so the pin is the deploy-time integrity guarantee), plus a cosign keyless signature and a SLSA provenance attestation. Independently verifiable out of band with scripts/verify-images.sh (offline, pinned Sigstore root). |
| Relay image | GitHub Actions CI | Same as the MCP server image. |
Base images of the two published images (python:3.12-slim-bookworm, node:20-bookworm-slim) |
Docker Hub | Digest-pinned (@sha256: arm64), so a rebuild cannot silently pull a different base. |
| Capsule MicroVM base | AWS-managed | Governed by the template's BaseImageArn (versioned aws:microvm-image:al2023-1), the AWS-controlled pin for the in-account build. The capsule Dockerfile FROM is for local builds only and is intentionally not pinned to a public base, to avoid conflicting with BaseImageArn. |
| Bundled capsule (Pairputer Workbench) build-context zip † | Project launch bucket (1-click only) | The tree SHA-256 is embedded in the filename (content-addressed). The in-stack manifest stager reads the validated capsule.manifest.json from that exact zip and stages an immutable, digest-chained SSM release: manifest digest, then release record, then an atomic /current pointer. |
| Third-party binaries the Workbench build fetches (gh, ripgrep, uv, code-server, FFmpeg, Chromium, Homebrew) † | GitHub releases and public mirrors | Version-pinned and SHA-256-pinned in the Dockerfile, verified during the in-account build. |
| Relay HMAC secret, CloudFront keys, origin secret | Generated in your account | Never distributed. They live only in your Secrets Manager. |
† These are reference-capsule artifacts. They exist only when the Bundle reference capsule parameter
is on, which is the default. With it off, the substrate deploys with no capsule, and neither the build
context nor its third-party binaries are fetched. The optional Agent DOOM cartridge
(deploy-capsule.sh agent-doom) follows the same rules: a tree-hashed context, and a DOOM1.WAD that
is SHA-256-pinned and verified during the in-account build.
Public mode pulls the images this project publishes from public.ecr.aws/b6x6x7v3/pairputer-mcp and
public.ecr.aws/b6x6x7v3/pairputer-stateful-relay. The b6x6x7v3 segment is the ECR Public registry
alias, which AWS assigns to the registry that owns those repos. An alias is not a secret; it appears in
every pull URL.
If you fork this repo and republish the images from your own CI and AWS account, you get a different alias. In that case, what you trust is not the alias string but two things that do not change:
- The digests pinned in
substrate/cloudformation/pairputer.yaml. - The signer identity the verification checks (
somoore/pairputer-on-awsthrough GitHub OIDC), which you set to your own repo when you republish (PAIRPUTER_SIGNER_IDENTITY_REGEXPinscripts/verify-images.sh).
GitHub Actions builds, pushes, and signs the public images, using GitHub OIDC as a single identity chain with no stored secrets:
-
Get AWS access without stored keys. The workflow assumes an IAM role through GitHub OIDC (
aws-actions/configure-aws-credentialswithrole-to-assume). The role's trust policy is scoped torepo:somoore/pairputer-on-aws:ref:refs/heads/mainand grants only ECR Public push. -
Build and push for ARM64, capturing the digest. Nothing downstream relies on the tag.
-
Sign the digest with cosign, keyless. The workflow has
id-token: write, and cosign uses the GitHub Actions OIDC token, so there is no private key:cosign sign --yes public.ecr.aws/<alias>/<repo>@${DIGEST}
The signature is stored as an OCI artifact in the same public repo. The signing event, meaning the CI identity plus the artifact digest, is recorded in the Rekor transparency log.
-
Attach SLSA build provenance with the same identity:
cosign attest --yes --type slsaprovenance --predicate provenance.json \ public.ecr.aws/<alias>/<repo>@${DIGEST}
Publishing to ECR Public (push and sign) runs in us-east-1 only. Verification runs from anywhere.
There is no native signature-admission hook in Bedrock AgentCore or in plain ECS/Fargate. Instead of
forcing a per-deploy verification gate into your account, the deploy-time integrity guarantee is the
digest pin itself: AgentCore and the relay task definition are pinned to a specific @sha256: digest,
which is immutable and content-addressed and cannot be swapped after the template is read.
You verify the signature and SLSA attestation out of band with scripts/verify-images.sh:
# Offline, against the pinned Sigstore root at scripts/sigstore-trusted-root.json.
scripts/verify-images.sh # verifies the digests the template pinsUnder the hood, for each digest:
cosign verify --offline --trusted-root scripts/sigstore-trusted-root.json \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--certificate-identity-regexp 'https://github.com/somoore/pairputer-on-aws/.github/workflows/.*@refs/heads/main' \
public.ecr.aws/<alias>/<repo>@sha256:<digest>
cosign verify-attestation --offline --trusted-root scripts/sigstore-trusted-root.json \
--type slsaprovenance <same identity flags> <same digest>Two properties make this reliable:
- Offline plus a pinned root means it fails closed. The Sigstore trust root is committed in the repo,
so verification does not depend on Sigstore or Rekor being reachable at verify time. A network outage
cannot make a bad image look good. cosign v3 pins the root through the
--trusted-rootflag; the olderSIGSTORE_ROOT_FILEenvironment variable is silently ignored for the new bundle format. - The in-stack gate is opt-in, not forced. A forced gate would run in your account (your CodeBuild or
Lambda, your cost, your egress, your failure surface) for a guarantee the immutable digest pin already
gives. A high-assurance in-stack gate can be added later, off by default. A container-Lambda gate is
not possible in any case: Lambda image functions can pull only from private, same-account ECR, not from
public.ecr.aws.
Referencing an image by @sha256:<digest> is immutable. It defeats tag mutation and TOCTOU swaps
outright, which matters because even ECR tag-immutability can be turned off by whoever controls the repo
configuration. Signing composes on top: sign the digest, verify the digest, deploy the digest. As
belt-and-suspenders, the public ECR repos are also set to immutable tags.
Supply-chain integrity ensures the right code runs. This section covers who is allowed to call it. Everything here is created fresh in your account. There is no shared identity, and no secret ever leaves your account.
Your user pool has one public Cognito app client per interactive host, plus a confidential smoke-test
client (identity.yaml):
| Client | Flow | Secret | Access token | Refresh token | Scopes |
|---|---|---|---|---|---|
| Codex (interactive) | authorization-code + PKCE | None (public client) | 8 hours | 30 days | openid, pairputer-mcp/invoke |
| ChatGPT (interactive) | authorization-code + PKCE | None (public client) | 8 hours | 30 days | Standard OIDC scopes, pairputer-mcp/invoke |
| Claude (interactive) | authorization-code + PKCE | None (public client) | 8 hours | 30 days | Standard OIDC scopes, pairputer-mcp/invoke |
| M2M (smoke tests only) | client_credentials | Yes (confidential) | 24 hours | None | pairputer-mcp/invoke |
Token revocation is on (the Cognito default), and self-sign-up is disabled. The pool is admin-created
users only (AllowAdminCreateUserOnly), so a leaked callback cannot register accounts. A regional WAF
sits in front of the Cognito hosted-UI login surface.
The MCP runtime is configured with a CustomJWTAuthorizer (agentcore.yaml) that points at the pool's
OIDC discovery document
(https://cognito-idp.<region>.amazonaws.com/<poolId>/.well-known/openid-configuration). It has an
AllowedClients allow-list of exactly the four client IDs above and an AllowedScopes requirement for
pairputer-mcp/invoke. AgentCore fetches Cognito's JWKS from that URL and verifies the token's
signature, issuer, expiry, client, and required scope on every call. An expired, wrong-issuer,
wrong-client, or scope-less token is rejected at the runtime before any tool code runs. Only the
Authorization header is allow-listed into the runtime; nothing else is forwarded. The MCP server holds
no static AWS keys; it acts through its IAM execution role.
The tenant model derives tenant_id = sha256(iss:sub). It previously trusted the decoded claims because
AgentCore had verified them upstream. That is safe, but it is a single point of failure if anything ever
lets a request reach the runtime with an attacker-set Authorization header.
server.py::_verify_jwt now independently re-verifies the RS256 signature, iss, and exp against
Cognito's JWKS inside the container (PAIRPUTER_JWT_DISCOVERY_URL; the JWKS is cached and refetched on
key rotation). It fails closed, and it runs before deriving the tenant. It is a no-op only when the
discovery URL is unset (local mode, or a not-yet-migrated deploy), so the tenant model no longer relies
solely on AgentCore being the only ingress.
CloudFrontKeyGroupId is a required stack parameter (MinLength: 1), and the distribution's
TrustedKeyGroups is unconditional. An empty value used to silently drop the signed-URL requirement,
collapsing "edge auth before origin" down to just the relay ?t= token. That gap is closed.
The Cognito token authorizes the control plane. It does not reach the relay.
When a session opens, the MCP server mints its own HMAC-signed relay token with a 15-minute TTL
(SESSION_TOKEN_TTL_SECONDS), scoped to specific channels (video, audio, input) and bound to the
DynamoDB session's ID and version. The matching CloudFront signed-URL policy carries the same exp. The
relay re-verifies the HMAC, the channel, and DynamoDB session freshness on every request, so a token
minted before a Freeze (which rotates the session version) is dead even inside its 15-minute window.
A chat thread left open past the TTL recovers by calling the authenticated MCP channel again
(pairputer_session) to mint a fresh relay token. The long-lived Cognito refresh token is what makes
that seamless without re-login. The MicroVM's own JWE (relay to VM) is minted server-side and never
reaches the browser.
The result: a long-lived identity (Cognito, 8h/30d, revocable) gates the control plane; a short-lived, session-bound, channel-scoped capability (15-minute HMAC plus a matching CloudFront policy) gates the data plane; and the two are independent, so compromising one grant does not hand over the other.
- No native admission control for signatures in AgentCore or ECS. The deploy-time guarantee is the
immutable digest pin; signature and provenance are verified out of band
(
scripts/verify-images.sh), not enforced by an in-stack admission gate. See Verify the images for the reasoning and the opt-in path. - The capsule MicroVM image cannot be pre-signed by the project.
AWS::Lambda::MicrovmImagebuilds in your account from the S3 context; it cannot import an ECR or prebuilt image. Its integrity rests on the content-addressed tree hash of the build context and the SHA-256-pinned third-party artifacts fetched during the build, both in your own account. - Long-lived AWS credentials are never stored in GitHub (OIDC only) and never distributed.