Skip to content

Hand-off contract, version 1

Design phase: not built yet. This page is the proposed specification for issue #7. Every choice that the issue left open is listed in Choices made, and what is still undecided is in Open.

A system that holds intermediate keys, called the requester on this page, and ritueel, called the certifier, depend on neither product nor shared code. They meet at standard formats, and this page is the one place that says exactly which. A requester can be built from this page alone, and so can a certifier. The reasons are in ADR 0001. The vocabulary (root, intermediate, ceremony) is in Roots, intermediates and transitions.

Kind The requester sends The certifier sends back
X.509 intermediate (x509-intermediate) A PKCS#10 certificate request, and a proof file A certificate, and a result file
OpenPGP signing subkey (openpgp-subkey) The public subkey and its back-signature as OpenPGP packets, and a proof file The subkey bound by a primary, as OpenPGP packets, and a result file

The key is made where it will live (a YubiKey slot, an AWS KMS key, or a file in development) and never leaves. Only public keys, signatures and certificates travel. The proof file says where the key lives and carries the evidence for it.

A message is a set of files that share a stem, a name the requester chooses: lower-case letters, digits and hyphens, 1 to 63 characters, starting with a letter or a digit. The certifier repeats the stem in its answer.

Message Files
X.509 request <stem>.csr.pem, <stem>.proof.json
OpenPGP request <stem>.subkey.pgp, <stem>.proof.json
Answer, always <stem>.result.json
X.509 answer, issued <stem>.chain.pem
OpenPGP answer, issued <stem>.bound.pgp

JSON files are one UTF-8 object, with no byte order mark and no more than 256 KiB. A member name appears once. A member this page does not define is an error, not something to skip. Numbers are integers. A time is YYYY-MM-DDThh:mm:ssZ in UTC (RFC 3339, whole seconds). A digest is SHA-256 in lower-case hexadecimal. A certificate or a public key inside JSON is standard Base64 (RFC 4648, with padding) of its DER encoding, as in the x5c member of a JSON Web Signature (RFC 7515, section 4.1.6).

Transport is not specified. The files may travel in a shared directory, on removable media, as attachments or as pipeline artifacts. No network protocol is defined, and nothing authenticates the requester. Authenticity comes from the ceremony: the requester prints the fingerprint of its key when it makes the request, and the person at the ceremony compares it with the one the certifier shows before anything is signed. Integrity of the answer comes from the answer itself: a certificate or a binding signature verifies against a root that the requester already trusts.

  • Every JSON file has the member handoff, an integer. This page is version 1.
  • A receiver that does not know the version refuses the message with version-unsupported and reads nothing else in it.
  • The answer carries the version of the request it answers. A requester refuses an answer whose version is not the one it sent.
  • Any change to a member, a rule, a profile or a reason code that can turn an accepted message into a refused one, or the reverse, is a new version. A rewording that changes no outcome in the test vectors is not.
  • The compatibility window. A release of the certifier states the versions it reads. It drops a version only in a major release, and not earlier than 24 months after the first release that read its successor. The reason is the yearly cadence: a requester installed a year ago must still be understood at its next ceremony, and a second year covers a missed one.
  • The test vectors are versioned with the contract (testdata/hand-off/v1/). A change to a vector that flips its outcome is a new contract version.

<stem>.proof.json ties a request to its evidence.

Member Required Meaning
handoff yes The contract version, 1.
type yes x509-intermediate or openpgp-subkey.
request_sha256 yes SHA-256 of the request: the DER of the CSR, or the bytes of the .subkey.pgp file. It binds the proof to its request and catches a swapped pair of files.
store yes Where the key lives: yubikey-piv, aws-kms or software.
evidence for yubikey-piv and aws-kms, absent for software The proof, as below.
primary for openpgp-subkey only The fingerprint (40 hexadecimal digits, version 4) of the primary that is to bind the subkey.
capabilities for openpgp-subkey only The list ["sign"]. Version 1 binds signing subkeys.
{ "attestation": ["<slot certificate, Base64 DER>", "<card certificate, Base64 DER>"] }

The YubiKey makes two certificates on request. The first is the attestation certificate of the key’s slot: it says that this key was made inside this token, and carries its policy. The second is the token’s own certificate (slot F9), which signed the first. The list is in this order, leaf first. Certificates above them (the maker’s intermediates) are not on the token. The certifier uses its own pinned copies, and it may use extra ones from the list as untrusted helpers. A root in the file is never trusted. Only the roots the certifier has pinned are.

What the certifier reads from the slot certificate (checked on a test YubiKey 5, firmware 5.8.0, on 2026-10-31):

Extension (1.3.6.1.4.1.41482.3.…) Content
.3 Firmware version, three raw bytes (05 08 00 is 5.8.0).
.7 Serial number, a DER INTEGER.
.8 Two raw bytes: PIN policy (1 never, 2 once, 3 always), then touch policy (1 never, 2 always, 3 cached).
.9 Form factor, one raw byte.

Firmware 5.7 and newer chain to the maker’s current attestation root; older tokens chain to the earlier PIV root. The certifier pins whichever roots the holder lists, so a chain that ends in an unpinned root is refused.

{ "key_arn": "arn:aws:kms:eu-west-1:123456789012:key/11111111-2222-3333-4444-555555555555",
"public_key": "<the public key, Base64 DER SubjectPublicKeyInfo>" }

The ARN names the key together with its Region and account. A bare key identifier does not, and the same identifier can exist in two Regions. The public key is a claim. The certifier does not trust it: it reads the key from KMS with DescribeKey, GetPublicKey and GetKeyPolicy, and compares. The requester has to give the certifier’s principal permission to read the key’s description and public key, and nothing more. This holds for a key in another account too: the certifier is granted read access and re-reads the public key itself. It never accepts a copy of the description or the key in the proof, not even one marked unverified. A proof whose key the certifier cannot read is refused (proof-invalid). GetKeyPolicy works only inside the key’s own account (AWS documentation, checked 2026-11-01); how the policy rule below applies to a key in another account is open.

No evidence. A software key proves nothing about where it lives. The certifier accepts it only when its policy says so, which is the development setting, and it records the key as unprotected.

<stem>.csr.pem is a PKCS#10 certificate request (RFC 2986), PEM-armoured with the label CERTIFICATE REQUEST.

  • The key is ECDSA on P-384 (id-ecPublicKey with secp384r1). Version 1 accepts no other key type.
  • The request is signed by that key with ecdsa-with-SHA384. This is the proof of possession; for a key in a YubiKey or in KMS the device makes the signature.
  • The subject is the name the requester wants in the certificate. The certifier issues it as asked or refuses; it never rewrites it.
  • The extensions the requester wants are in the standard extensionRequest attribute (RFC 2985, section 5.4.2). Version 1 knows these:
Extension In the request
Basic constraints Critical. cA true, path length 0.
Key usage Critical. keyCertSign is required. cRLSign and digitalSignature may be added.
Name constraints Critical. At least one permitted subtree.
Extended key usage Optional.

Any other extension is refused with policy.

The certifier issues exactly what was asked, or refuses. The request is the requester’s statement of how much power it needs, and the policy of the certifier says how much it may have. A request for more than the policy allows is refused, not trimmed. Validity is the one thing the request does not carry: the certifier decides it.

In this order. The first check that fails gives the reason code; the others are not reported.

# Check Code
1 The contract version is known. version-unsupported
2 The files parse, with no unknown or repeated member. malformed
3 request_sha256 is the digest of the request. request-mismatch
4 The key is ECDSA P-384. algorithm-unsupported
5 The request’s signature verifies with its own key. possession-invalid
6 The store is allowed by the policy. store-not-allowed
7 The evidence is valid, as below. proof-invalid
8 The key in the evidence is the key in the request. key-mismatch
9 The policy accepts the request, as below. policy
10 The person at the ceremony confirms. declined

Evidence for a YubiKey (check 7). Both certificates parse. The chain from the slot certificate through the token’s certificate and the maker’s intermediates leads to a pinned root, with every certificate valid at the time of the ceremony; the token’s certificate is a CA with path length 0. The slot certificate’s subject is YubiKey PIV Attestation <slot>, where the slot is one of 9a, 9c, 9d, 9e or 82 to 95, and the extensions .3, .7 and .8 are present. Evidence for KMS (check 7). The key’s state is Enabled, its usage SIGN_VERIFY, its spec ECC_NIST_P384, its origin AWS_KMS and its manager CUSTOMER, and the ARN in the proof is the ARN KMS reports.

Key match (check 8). The attested public key (YubiKey), the key KMS returns, and the public_key in the proof, where one is given, are all the key in the request.

Policy (check 9). The policy is the certifier’s, and is not part of the wire format. The reference policy of the test vectors has these rules, and a certifier may add more:

Rule Reference value
Firmware of a YubiKey 5.7.0 or newer.
PIN policy of the key Once or always. Never is refused.
KMS key policy Every statement that allows kms:Sign (directly, or through kms:*) names the same single principal: a role or a user, not a wildcard and not the account root.
Name constraints Present, and every permitted subtree inside the subtrees the policy lists for the requester.
Validity 13 calendar months.

Touch policy and form factor are read and recorded, and the reference policy does not enforce them.

The profile of the certificate. The requester can check every row.

Field Value
Version, serial 3. A positive number of at most 20 bytes from a cryptographic random source, with at least 64 random bits.
Signature ecdsa-with-SHA384, made by a root on P-384.
Issuer The subject of the issuing root.
Subject, public key The CSR’s, unchanged.
Validity notBefore is the ceremony time; notAfter is 13 calendar months later (same day and time of the month, or the last day of the month if it has none), both UTC.
Basic constraints Critical. cA true, path length as requested (0 in version 1).
Key usage Critical. Exactly as requested.
Name constraints Critical. Exactly as requested.
Extended key usage Exactly as requested, or absent.
Subject key identifier Not critical. The leftmost 160 bits of the SHA-256 of the subject public key bits (RFC 7093, section 2, method 1).
Authority key identifier Not critical. The keyIdentifier of the issuing root, computed the same way.
Anything else Absent.

<stem>.chain.pem holds the issued certificate, and in version 1 nothing else, because a root issues an intermediate directly. The root is not in the file. The requester takes its anchors from its own root list, never from the answer. result.json names the issuing root so the requester can tell which anchor to use.

What the requester checks before it installs

Section titled “What the requester checks before it installs”

Each failure gives one of these codes. They belong to the requester and are not sent anywhere.

Check Code
The answer’s version is the one sent, and request_sha256 is the digest of its own request. version-unsupported, request-mismatch
The certificate verifies to an anchor in its root list, which is at least trust_sequence of the answer. issuer
The public key is its own key. key-mismatch
The subject, basic constraints, key usage, name constraints and extended key usage are exactly what it asked. No extension outside the profile is critical. profile
The validity is the length it expects (for example 12 to 14 months) and has not ended. validity
The digests in result.json match the files. malformed

Version 1 uses version 4 keys: the primary and the subkey are ECDSA on P-384 (algorithm 19, curve OID 1.3.132.0.34). RFC 9580 (section 5.2.3.2) wants a digest of at least 384 bits for P-384. Every signature of the contract uses SHA-384, which is also the 48-byte digest a YubiKey or a KMS key signs as it is given. Other algorithms are refused with algorithm-unsupported. OpenPGP version 6 keys wait for a later version of the contract.

<stem>.subkey.pgp is binary OpenPGP and holds exactly two packets, in this order:

  1. A Public-Subkey packet (tag 14) with the subkey.
  2. A Signature packet (tag 2) of type 0x19, the primary key binding, also called the back-signature. It is made by the subkey, and says that the subkey agrees to belong to the primary (RFC 9580, section 5.2.1.9). It signs the primary key and then the subkey, each as 0x99, a two-byte length and the packet body, followed by the usual signature trailer. Its hashed area has the signature creation time and the issuer fingerprint of the subkey.

Because the back-signature covers the primary’s key, the requester must know which primary will bind it before it makes the signature. The proof names it in primary. One request names one primary. A requester that wants to be bound by both roots makes two requests for the same subkey.

A subkey whose back-signature is missing, of another type, made over another primary, or damaged is refused with possession-invalid. GnuPG and Sequoia refuse a signing subkey without a good back-signature, so the certifier does not bind one.

The same ten checks and codes as for X.509, with these differences.

# In OpenPGP
3 After the digest, primary must be a primary the certifier holds. Otherwise unknown-primary.
4 The subkey is ECDSA P-384.
5 The packets are the two above, the back-signature has type 0x19 and verifies with the subkey over the named primary and the subkey. capabilities is ["sign"].
7, 8 As for X.509. The key in the evidence is the point of the subkey.
9 Reference policy: as for X.509, without the name constraints rule.

<stem>.bound.pgp holds two packets: the same Public-Subkey packet, and a subkey binding signature (type 0x18) made by the primary:

Part Value
Hashed area Signature creation time (the ceremony time). Key flags: 0x02, signing only. Key expiration time, marked critical, in seconds after the subkey’s creation, so that the subkey ends at the same instant a certificate issued at that ceremony would. Issuer fingerprint of the primary.
Unhashed area Issuer key ID. Embedded signature (subpacket 32): the request’s back-signature, unchanged.

Add the packets to the primary’s certificate to get the updated certificate, with any tool that concatenates files:

Terminal window
cat test-primary-a.pgp pgp-piv-valid.bound.pgp > updated.pgp

A verifier then needs nothing from ritueel. The vectors were checked this way with sqv: a signature made by an accepted subkey verifies against updated.pgp, and one made by a subkey whose answer lacks the embedded back-signature, or was bound by another primary, does not. The ritueel keyring package carries the same certificates (issue #15).

What the requester checks before it installs

Section titled “What the requester checks before it installs”
Check Code
The version, and request_sha256, as for X.509. version-unsupported, request-mismatch
The first packet is its own subkey. key-mismatch
The binding verifies, with the primary’s key from its keyring, over that primary and the subkey, and the primary is the one it named. issuer
The key flags are exactly signing. The embedded signature is its own back-signature. profile
The expiry is the length it expects, and has not passed. validity

<stem>.result.json:

Member Meaning
handoff, type, request_sha256 As in the request. request_sha256 is the digest of the request that was answered.
outcome issued or refused.
reasons When refused: a list of one or more codes from the table below.
artifacts When issued: a list of { "name", "sha256" }, one for each file of the answer other than this one.
valid_from, valid_until The validity that was issued.
issuer For X.509, the SHA-256 of the DER SubjectPublicKeyInfo of the issuing root. For OpenPGP, the fingerprint of the primary.
trust_sequence The sequence number of the trust set (root list, CA bundle and keyring) that the answer belongs to. The requester must hold a root list at least this recent before it installs.

A refused answer holds no artifacts and no secret. The codes:

Code Meaning
version-unsupported The contract version is not known.
malformed A file does not parse, or has an unknown or repeated member.
request-mismatch The proof is not for this request.
unknown-primary OpenPGP only: the primary is not one the certifier holds.
algorithm-unsupported The key is not ECDSA P-384.
possession-invalid The request’s signature, or the back-signature, does not verify or is not the right kind.
store-not-allowed The policy does not allow this key store.
proof-invalid The evidence does not verify.
key-mismatch The evidence is for another key.
policy A rule of the certifier’s policy refuses it.
declined The person at the ceremony did not confirm.

The answer does not say which part of a policy failed. The certifier’s output and its ceremony record do.

Trust products. The CA bundle, the keyring and the signed root list are not part of this contract, and the certifier publishes them as it does for everyone. Their formats are issue #14 and issue #15. The contract fixes one thing about them: they carry a trust sequence, one integer that grows by one each time any of them changes, and trust_sequence in the answer says which one the answer needs.

The ceremony record (issue #9) gets, for each request: the digests of the request and the proof; the store; the fingerprint of the key; for a YubiKey the serial, firmware, PIN and touch policy and form factor read from the attestation; for KMS the ARN, the key’s description and the digest of its policy; the policy that was applied; the result and the digests of the artifacts. It never holds a PIN, a passphrase or a key. The record does not decide anything on this page.

The vectors are in testdata/hand-off/v1/. They are public material only: CSRs, certificates, public subkeys, signatures and JSON files. Every key was made in software by generate.py, and its private half was never written down. The attestation hierarchy is a made-up “Example Test” hierarchy, not the maker’s, and the certifier’s roots are test roots.

  • manifest.json lists every case with its files, the policy configuration it runs under, and the expected outcome and reason. A test in the Go code will read it (issue #7, done when).
  • pki/ holds the pinned test attestation root and intermediate, the test roots and primaries the certifier would hold.
  • The clock of every vector is 2026-11-15T12:00:00Z. A test fixes its clock to that time, so the vectors do not expire.
  • Two configurations: production (a YubiKey or KMS key; software refused) and development (software allowed). Both use the reference policy above.
  • A KMS case comes with <case>.kms-stub.json: the answers of DescribeKey, GetPublicKey and GetKeyPolicy that a test replays instead of calling AWS.
Group Accepted Refused
X.509 requests A YubiKey, a KMS key, a software key in development. Software in production, a bad CSR signature, a P-256 key, version 2, a proof for another request, an unknown member, an attested key that is not the request’s, an untrusted attestation chain, PIN policy never, old firmware, no name constraints, a name constraint outside the policy, a KMS key that is not the request’s, a KMS policy that names two actors.
X.509 answers A YubiKey, a KMS key, a software key. Another key, path length 1, expired, an issuer that is not in the root list.
OpenPGP requests A YubiKey, a KMS key, a software key. No back-signature, a back-signature over another primary, of type 0x18, or damaged; version 2; an unknown primary; an attested key that is not the subkey; an Ed25519 subkey.
OpenPGP answers A YubiKey, a KMS key, a software key. No embedded back-signature, another subkey, another primary, an expiry of two months.

A certifier passes when its outcome and reason code match the manifest for every request; a requester passes when its decision and code match for every answer. The vectors fix the outcome and the code, not the wording of a message.

The issue left these to the specification. Each is a proposal.

Choice Chosen Other options Why
Encoding of the proof file JSON, with certificates and keys as Base64 DER. CBOR; ASN.1 DER; one PEM file with headers; a TAR holding everything. Readable by a person who must compare it with what they expect. Every language has a strict parser. The x5c precedent covers the encoding of certificates. CBOR and DER are smaller and harder to read; PEM headers have no standard shape.
File names <stem>.csr.pem, <stem>.subkey.pgp, <stem>.proof.json, <stem>.result.json, <stem>.chain.pem, <stem>.bound.pgp. A directory per request with fixed names. A stem lets several requests share a directory, and the extension says the format. A directory is cleaner for one request.
Version One integer in every JSON file. Unknown versions are refused. major.minor with ignorable additions; a media type. A security contract should not skip what it does not understand. Strict members mean every addition is a new version, and the window makes that cheap.
Compatibility window Read the previous version for 24 months after its successor first ships. A fixed 12 months; “any older version, always”. Ceremonies are yearly. A year is not enough for a missed one.
Unknown members Refused. Ignored. An ignored member is a field one side thinks it understands.
Binding of proof to request request_sha256. Nothing; a signature by the key over the proof. It catches swapped files without a second signature, and the request is already signed by its key.
KMS key reference The full ARN. The bare key identifier. The ARN carries Region and account, which the certifier needs to read the key.
KMS key in another account The certifier is granted read access to the key’s description and re-reads the public key. Accept a snapshot of the description in the proof and mark it unverified. A copy is a claim, and the contract’s rule is that the certifier reads the key itself.
Chain file The issued certificate only; the root is named in result.json. The chain with the root included. A root in a file invites trusting it.
Who decides validity and constraints The certifier decides validity. The requester asks for constraints in the CSR, and the certifier issues them exactly or refuses. The certifier overrides the request; the requester asks for validity. Silent change is the failure to avoid: the requester then installs something it did not ask for.
OpenPGP request Two packets, plus the proof naming the primary. The whole primary certificate in the request; a back-signature for every primary at once. The primary is already held by the certifier. One primary per request keeps each file small, at the cost of a second request for the second root.
OpenPGP answer The subkey and its binding, to be appended to the primary’s certificate. The complete updated certificate. The answer is small and checkable by concatenation. The keyring package carries complete certificates.
Embedded back-signature In the unhashed area of the binding. In the hashed area. The way GnuPG, Sequoia and sqv were tested and measured on 2026-10-31 and 2026-11-01. Both areas are legal (RFC 9580, section 5.2.3.34).
Order of checks Fixed, with one code. Report every failure. The vectors then have one right answer, and a person sees the first problem to fix.
Algorithm ECDSA P-384, SHA-384, version 4 keys. P-256; Ed25519; version 6 keys. P-384 works on every store and with GnuPG, gpgv, sqv, apt and rpm (tested 2026-10-29 and 2026-10-31). A PIV slot signs a whole Ed25519 message and refuses more than about 3 KiB, so it cannot sign an artifact. GnuPG 2.4 does not handle version 6.
Fixture generator A Python script, kept next to the fixtures. Go, with the first test; none. It was the fastest way to make fixtures with independent code. It should become Go once the module exists, so that one toolchain builds everything.
  • Non-CA signing certificates (for keys that sign tokens and are published with a chain). Version 1 issues CA certificates only. Options: add a second profile in version 1; allow digitalSignature without keyCertSign in this one; wait for a later version. Issue #7.
  • How the requester learns the target primary. Options: the person tells it, from the keyring; the certifier’s plan prints the command; the request covers every active primary. Issue #7.
  • The key policy of a key in another account. The certifier reads the description and the public key across accounts, but GetKeyPolicy does not work across accounts, so the single-principal rule of the reference policy cannot be checked the same way. Options: apply the rule to keys in the certifier’s own account only, and record that the policy of another account’s key was not checked; refuse a key in another account under the reference policy. Issue #12.
  • The format and expiry of the root list, and so the rule for trust_sequence. Issue #14.
  • GnuPG was not run on the OpenPGP vectors. A test with gpg and gpgv should join the verifier matrix (issue #24).
Standard Used for
RFC 2986 PKCS#10 certificate request.
RFC 2985 The extensionRequest attribute.
RFC 5280 The certificate profile, name constraints, key usage.
RFC 7093 The key identifiers.
RFC 9580 OpenPGP packets, signature types 0x18 and 0x19, subpackets.
RFC 7515 The x5c precedent for certificates in JSON.
RFC 4648 Base64.
The maker’s PIV attestation documentation The extension arc 1.3.6.1.4.1.41482.3 and the attestation chain. The encodings above were measured on 2026-10-31.
AWS KMS API DescribeKey, GetPublicKey, GetKeyPolicy, Sign.