{"article":{"slug":"understanding-nvpcrs-in-systemd-v262","title":"Understanding NvPCRs in systemd v262","subtitle":null,"summary":"systemd answers TPM PCR scarcity with additional PCR-like registers allocated in the TPM’s NV memory, with an anchoring design that was reworked in v262.","content_type":"blog_post","language":"en","canonical_url":"https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/","author":{"name":"Paul Meyer","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"katexochen","url":"https://katexochen.aro.bz","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Systems Programming","slug":"systems-programming","url":"https://listedarticles.com/topics/systems-programming"},{"name":"Infrastructure","slug":"infrastructure","url":"https://listedarticles.com/topics/infrastructure"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":6748,"reading_minutes":29,"published_at":"2026-09-22T12:00:00.000Z","added_at":"2026-09-25T21:17:55.001Z","updated_at":"2026-09-25T21:17:55.001Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":false},"profile_url":"https://listedarticles.com/articles/understanding-nvpcrs-in-systemd-v262","markdown_url":"https://listedarticles.com/articles/understanding-nvpcrs-in-systemd-v262.md","example":false,"citation":"Paul Meyer, katexochen. \"Understanding NvPCRs in systemd v262.\" 22 Sept 2026. https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://katexochen.aro.bz/posts/systemd-v262-nvpcrs/"},"body_markdown":"*systemd answers TPM PCR scarcity with additional PCR-like registers allocated in the TPM’s NV memory,\nwith an anchoring design that was reworked in v262.\nIn this hands-on deep dive, we rebuild a systemd NvPCR from scratch against a software TPM,\npicking up the required TPM concepts along the way, and analyze why the design is secure.*\n\n## Why can’t systemd get enough of those PCRs?\n\nMany of systemd’s security features rely on TPM PCR measurements: Passwordless full disk encryption can unlock disks automatically if the PCR measurements are as expected, preventing credential theft while still allowing unattended reboots of remote machines with encrypted root disk. Service credentials can be encrypted against the expected PCR state. Boot-phase bound credentials are also supported, allowing secrets that can only ever be decrypted in the initrd. And with remote attestation, a machine can prove to another party what it booted and what happened since, by having the TPM sign its current PCR state (a so-called quote). All of this is built on PCR measurements.\n\nTPM PCRs are scarce. On common standard-compliant TPMs there are only 24 PCRs available.\nThe lower PCR indices 0-7 are owned by the firmware and used for UEFI boot measurements.\n16 is a debug PCR that can be reset and is therefore unusable, 17-22 are reserved for\nDynamic Root of Trust for Measurements<sup>1</sup>, and 23 is reserved for application support.\nSo only 8-15 are left for systemd to do all OS-related measurements<sup>2</sup>.\n\nFrom a remote attestation perspective, a single measurement register can be enough to verify a system. We can use the measurement log to replay the events and interpret what was measured to land on the final value we observe. But PCRs are not only read by remote verifiers, they are also what local secrets are locked against, and that use needs predictable values: every event flowing into such a PCR must be known in advance, otherwise the policy breaks. Some measurements are inherently unpredictable, depending for example on the closed source vendor firmware of the individual platform, or on the behavior of users. We might want to include a login event in our remote attestation quote, but the root disk should still be able to unlock itself after someone logged in. So the noisy and unpredictable event types need registers of their own: out of the way of the PCRs that locks depend on, but still measured and attestable. And that’s where systemd was quickly hitting a hard limit: with only eight PCR slots available to the OS, there isn’t much space to give event types their own register.\n\nThis is why systemd introduced NvPCRs in v259: additional PCR-like registers, allocated in the TPM’s non-volatile memory, hence the name. They host the event types we don’t want in the real PCRs, and their values are consumed through remote attestation. In v262, the anchoring of NvPCRs was reworked to increase their security. Previously, the anchoring was based on a random secret sealed against PCR 11 and stored on disk, which an attacker could recover by booting a different OS that replays the expected PCR 11 values, or simply replace with a secret they know. This blog post describes the reworked design that ships in v262: how it works, and why it is secure.\n\n## Setup for follow-along\n\nWe’ll check out the basic working principles and get a feeling for the matter by constructing our own NvPCRs against a software TPM on the command line. If you’d rather just read, that’s fine, too, I’ll provide all important output.\n\nAs a prerequisite, install the required tools, for example with nix or dnf:\n\n```\nnix shell nixpkgs#{tpm2-tools,xxd,swtpm,openssl}\n```\n```\ndnf install tpm2-tools vim-common swtpm openssl\n```\nCreate a directory you want to work in and start the software TPM:\n\n```\nmkdir state\nswtpm socket \\\n  --tpm2 \\\n  --tpmstate \"dir=$PWD/state\" \\\n  --ctrl \"type=tcp,port=2322\" \\\n  --server \"type=tcp,port=2321\" \\\n  --flags startup-clear \\\n  --pid \"file=$PWD/swtpm.pid\" \\\n  -d\n```\nExport the connection details so tpm2-tools knows where to find the TPM:\n\n```\nexport TPM2TOOLS_TCTI=\"swtpm:host=127.0.0.1,port=2321\"\n```\nCheck the TPM is working by reading the PCRs:\n\n```\ntpm2_pcrread sha256\n```\nThis should show the PCRs 0-16 being all zero. If not, you might be talking to the TPM of your\nplatform and not the software TPM, check again that you exported `TPM2TOOLS_TCTI` correctly.\nThis is important, as we don’t want the following experiments to mess with the sealed\nsecrets of your platform.\n\n## TPM NV index as PCR replacement\n\nA TPM NV index<sup>3</sup> is a non-volatile storage slot identified by a unique name. NV indices\npersist across reboots and can hold user defined data: an opaque value, a counter, bitfield\nor similar. The properties of an NV index define how it behaves and what it can be used for:\nits handle, the size of the stored data, a set of attributes controlling how the index can be\nmanipulated or read, and an authorization policy and authorization value (the latter being the\nonly non-public property) that optionally specify under which conditions the index can be\nmanipulated. Each index has a *nameAlg*, the hash algorithm used to compute the unique name\nfrom the public properties of the index<sup>4</sup> as\n\n`Name = nameAlg || H_nameAlg(marshal(TPMS_NV_PUBLIC))`.\n\nLet’s create a PCR-like NV index! We are using tpm2_nvdefine from tpm2-tools for this.\n`0x01000000` is the handle for the index that we are defining (somewhat randomly picked).\nThe `--hierarchy=o` flag selects the authorization we define the index under: NV indexes\nlike ours live in the TPM’s owner hierarchy, and defining or undefining them requires the\nowner authorization value<sup>5</sup>. On a typical Linux system that value is empty, so effectively\nanyone with access to the TPM device, usually root, holds owner authorization.\nThe `hash-algorithm` flag corresponds to the previously named nameAlg and is chosen as sha256.\nThen we select the attributes for our NV index: `authread|authwrite`, in combination with an\nempty authorization value, allows anyone with access to the device to read and write the\nindex. And `nt=extend` says we want this to be extendable like a PCR.\n\n```\ntpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=\"nt=extend|authread|authwrite\"\n```\nTake a look at the result using the following command:\n\n```\ntpm2_nvreadpublic\n```\n```\n0x1000000:\n  name: 000be9606b61ec27bc8deec096dd38a6f8961cb8b3ef2fe879b27de704ab2f3d44e3\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: authwrite|nt=0x1|authread\n    value: 0x40044\n  size: 32\n```\nWe can see the properties we configured<sup>6</sup>, the size, and the name that is a hash over\nthe public properties. As you defined the exact same properties as I did, you will get\nexactly the same hash as index name.\n\nNow we can use our NV index like a proper PCR and extend it with a measurement, for example with a login event of user Alice:\n\n```\nprintf 'user-alice-logged-in' > m1.bin\ntpm2_nvextend 0x01000000 --input=m1.bin\n```\nThen read its value:\n\n```\ntpm2_nvread 0x01000000 --size=32 | xxd -p -c 64\n```\n```\nbd9927a653c6c33297b7d884a8ad99df0f5b5b1c1e1e86f762b3aced8bc77f50\n```\nIf we now inspect the NV index again, we can observe something interesting:\nThe index got a new attribute `written`, indicating that the index has been written\none or more times. As the set of attributes was updated, and the name of the index\nis a hash including the attributes, it got a new name too! We will make use of this\nlater.\n\n```\ntpm2_nvreadpublic\n```\n```\n0x1000000:\n  name: 000b1191942a636c11a57571f0d435c290a1955788229305ce0aa8a2f393e9ed770c\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: authwrite|nt=0x1|authread|written\n    value: 0x20040044\n  size: 32\n```\nYou can do another measurement if you like, measuring the login event of Bob:\n\n```\nprintf 'user-bob-logged-in' > m2.bin\ntpm2_nvextend 0x01000000 --input=m2.bin\ntpm2_nvread 0x01000000 --size=32 | xxd -p -c 64\n```\n```\ne758d6e2e54620fd45b9cd1fd57cbba62757f12751d549fd2fc957ed1908ed29\n```\nAt this point, the value of the NV index is `HASH(HASH(0x0 || m1) || m2)`<sup>7</sup>.\nThe index name didn’t change again with the second measurement.\n\nSo we have an NV index that is extendable in the same way a PCR is. Can we already use it as PCR replacement? We can’t. We are missing a fundamental property of the real PCRs: To use the measurement chain as proof for anything, it must not be resettable or replayable during the runtime of the system! Otherwise an attacker that gained access to the system could just reset the measurement history and replay the history of an unmanipulated system, making the attack undiscoverable through remote attestation.\n\nSadly, this isn’t a property the TPM grants us for our PCR-like NV index: We defined the index at system runtime, and we can undefine it again:\n\n```\ntpm2_nvundefine 0x01000000 --hierarchy=o\ntpm2_nvreadpublic\n```\nGiven the two example measurements we did in this section, if Alice has malicious intentions and gains root access, and with it owner authorization, they can just undefine and redefine the index, then replay a history where Alice never logged in. The redefined index gets the exact same name, and we couldn’t notice.\n\nWhat we need is an index that anyone can extend, but that nobody can restart during the runtime of the system. Policies are the TPM’s tool to express such conditions.\n\n## Exploring policies\n\nA very powerful concept the TPM interface provides is the policy<sup>8</sup>.\nSuch a policy is an opaque digest stored with the object it protects, in the `authPolicy`\nproperty we already saw in the public properties of our NV index.\nTo fulfill a policy, we request a policy session from the TPM. Each session has its own\ncontext, containing a digest called `policyDigest` and a set of constraints that can be\nmodified by executing policy assertions. A fresh session has a `policyDigest` that is all zero.\nWithin a session, we can then run different policy commands against the TPM, each\nasserting some condition. Some assertions are checked immediately while the command\nexecutes. Others are deferred: the command records a constraint in the session context,\nand the TPM only checks it once the session is used for authorization. Either way, each\ncommand extends the session’s digest, using the following logic:\n\n`policyDigest := H(policyDigest_old || commandCode || command-specific args)`\n\nThat extend scheme works exactly like a PCR, even though the `policyDigest` is not backed by a real PCR.\nA caller can present different types of evidence, each extending the `policyDigest`.\nIf the assertions add up so that the session’s `policyDigest` matches the `authPolicy`\nof the index, the session is authorized and the desired command can be called with it.\nThe author of a policy computes the expected `authPolicy` digest via a trial session\nin a trusted environment or through pre-calculation offline. A trial session runs the\nsame digest calculation, but doesn’t verify any of the conditions, and in exchange can’t\nbe used to authorize anything. This is what allows an author to compute a policy for a\nstate the machine currently isn’t in.\n\n### `PolicyPCR`\n\nThe cool thing is that policies can make permission depend on the machine state by\nlocking against the expected value of a PCR. Let’s create such a policy!\nFirst, we start a trial session to define the policy we want to set on our NV index.\nThis is done by invoking `tpm2_startauthsession` without the `--policy-session` flag.\n\n```\ntpm2_startauthsession --session=trial.ctx\n```\nWe then call `tpm2_policypcr` to create an assertion over the currently observed\nPCR value(s) we select via `--pcr-list=`. For our experiment, we use PCR 15, one of\nthe OS-owned PCRs. The application PCR 23 might look like the natural playground, but\nlike the debug PCR 16 it can be reset at runtime, which is exactly the property we are\ntrying to get rid of:\n\n```\ntpm2_policypcr --session=trial.ctx \\\n  --pcr-list=sha256:15 \\\n  --policy=pcr15.policy\n```\n```\n7e247a603cd1052cabc095741b8ee2f7458aabeee960b8ec97d7f090171a039a\n```\nThe resulting policy digest is printed and written to `pcr15.policy`<sup>9</sup>.\nThen end the session:\n\n```\ntpm2_flushcontext trial.ctx\n```\nLet’s recreate the NV index from before, this time we protect write access to it with the policy we just created:\n\n```\ntpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=\"nt=extend|authread|policywrite\" \\\n  --policy=pcr15.policy\n```\nNotice we added both the `--policy=` flag and the `policyWrite` attribute.\nWe leave the `authRead` untouched. There is a `policyRead` too, but usually\nrestricting who can extend the index is much more interesting.\n\n```\ntpm2_nvreadpublic\n```\n```\n0x1000000:\n  name: 000b14a6915e5830ff2b83ebc87cdf5ac481fb29637c246489bdab1bba19948bb729\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: policywrite|nt=0x1|authread\n    value: 0x40048\n  size: 32\n  authorization policy: 7E247A603CD1052CABC095741B8EE2F7458AABEEE960B8EC97D7F090171A039A\n```\nJust trying to extend as before will now fail with an authorization error:\n\n```\ntpm2_nvextend 0x01000000 --input=m1.bin\n```\n```\nERROR: Esys_NV_Extend(0x12F) - tpm:error(2.0): authValue or authPolicy is not\navailable for selected entity\n```\nInstead, to write to the index, we need to start a policy session and satisfy the policy by\npresenting the current PCR 15 state<sup>10</sup>. After that, we can do the extension, presenting the session\nas authorization. And remember to flush the session context at the end.\n\n```\ntpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:15\ntpm2_nvextend 0x01000000 \\\n  --hierarchy=0x01000000 \\\n  --auth=session:s.ctx \\\n  --input=m1.bin\ntpm2_flushcontext s.ctx\n```\nIf PCR 15 advances, the policy can’t be fulfilled anymore. Run the following command to extend the PCR:\n\n```\necho something | tpm2_pcrevent 15\n```\nNow retry the three steps from before that unlocked the session based on the PCR.\nThe extend will fail with `tpm:session(1):a policy check failed`, as the PCR advanced and\nneither its current value (nor any of its future values!) matches the one included in the\npolicy anymore. The access to the PCR-bound index expired and can’t be regained during the\nruntime of the machine.\n\nFinally, undefine the index again with:\n\n```\ntpm2_nvundefine 0x01000000 --hierarchy=o\n```\n### `PolicyAuthorize`\n\nLocking against a PCR with `PolicyPCR` is super cool, but it is also brittle<sup>11</sup>: At the\nend of the previous section, a measurement changed, and our access expired with no way\nto regain it. That is a problem, because PCR values change for legitimate reasons all\nthe time. Think of the passwordless disk unlock from the intro: the disk key is sealed\nagainst the PCR state of the boot chain, and the next kernel update changes exactly\nthat state. The disk wouldn’t unlock anymore, even though nothing bad happened, and we\ncertainly don’t want to re-encrypt the disk on every update.\n\nWhat would be nice to have instead is a policy that stays stable while the approved\nstate can change. Luckily, there is another mechanism that can be used to create a policy:\n`PolicyAuthorize`. It introduces a level of indirection and delegation, allowing us to create a\npolicy from a public key instead of a system state. To authorize, you satisfy another, concrete\npolicy (like a `PolicyPCR`), then present a signature over the expected policy digest and a\n`policyRef`. The concrete policy digest itself is not part of the policy. Whoever owns the\nkey can approve new concrete policies, offline. The `policyRef` scopes the signatures\nso the signed policy can’t be used out of context.\n\nLet’s create a RSA key pair to explore `PolicyAuthorize`:\n\n```\nopenssl genrsa -out sign.key.pem 2048\nopenssl rsa -in sign.key.pem -pubout -out sign.pub.pem\n```\nLoad the public key into the TPM so we can use it as part of a policy:\n\n```\ntpm2_loadexternal \\\n  --hierarchy=o \\\n  --key-algorithm=rsa \\\n  --public=sign.pub.pem \\\n  --key-context=signkey.ctx \\\n  --name=signkey.name\n```\n```\nname: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661\n```\nThe printed name is what will bind our policy to this key. And it is computed just like\nthe NV index names we saw earlier: as a digest over the object’s public properties, which for\na key includes the public key itself. `tpm2_readpublic` shows these public properties:\n\n```\ntpm2_readpublic --object-context=signkey.ctx\n```\n```\nname: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661\nqualified name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661\nname-alg:\n  value: sha256\n  raw: 0xb\nattributes:\n  value: userwithauth|decrypt|sign\n  raw: 0x60040\ntype:\n  value: rsa\n  raw: 0x1\nexponent: 65537\nbits: 2048\n...\nrsa: ...\n```\nBesides the name algorithm, the object attributes and the key parameters, the public area contains the raw RSA modulus (shortened here). Any change to the public key changes the name, and with it any policy created from that name.\n\nNow use another trial session and the key name to create a policy that can be authorized using this key:\n\n```\nprintf 'demo' > policyref.bin\ntpm2_startauthsession --session=trial.ctx\ntpm2_policyauthorize --session=trial.ctx \\\n  --name=signkey.name \\\n  --qualification=policyref.bin \\\n  --policy=authorized.policy\ntpm2_flushcontext trial.ctx\ntpm2_flushcontext --transient-object\n```\n```\n6120c875c3afb0b2811fc7c3c045c32fb53596e8009e96855ed4aa6c332d0bfb\n```\nThe resulting policy digest contains no PCR value at all, it only depends on the\nkey name (and through it on the public key) and the `policyRef` label. As you\ngenerated a different key pair, the policy hash you will get will differ, too.\nNotice the second flush invocation with `--transient-object`: it removes the\nkey object that `tpm2_loadexternal` left behind in the TPM’s limited\ntransient memory. tpm2-tools doesn’t flush what it loads, so we\nwill repeat this cleanup after commands that load keys.\nThe printed policy hash is written to `authorized.policy`,\nwhich we can then use to define our NV index again, similar to how we did it before:\n\n```\ntpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=\"nt=extend|authread|policywrite\" \\\n  --policy=authorized.policy\n```\nFor now, nobody can write this index, because no signature satisfying the policy exists yet.\n\nIn practice, this flow will usually have two parties: the key holder is for example\na team of distro maintainers. The individual machine is the other party that presents\nevidence to its TPM. The key holder computes a concrete policy digest to approve,\nfor example a `PolicyPCR` for a specific build, then creates a signature over that digest\nand the `policyRef`.\n\nCreate a new PCR policy for the updated value of PCR 15, that’s what we want to bless now. Like in the previous section we run a trial session to get a policy digest for the observed state:\n\n```\ntpm2_startauthsession --session=trial.ctx\ntpm2_policypcr --session=trial.ctx \\\n  --pcr-list=sha256:15 \\\n  --policy=approved.policy\ntpm2_flushcontext trial.ctx\n```\nConcatenate the policy and the policyRef:\n\n```\ncat approved.policy policyref.bin > tbs.bin\n```\nThen sign the whole thing with the private key:\n\n```\nopenssl dgst -sha256 -sign sign.key.pem -out approved.sig tbs.bin\n```\nThe policy and its signature can then be shipped to a machine, for example as part of an image or update. This is the artifact showing the key holder approved this concrete policy.\n\nOn the machine, we let the TPM verify the signature first:\n\n```\ntpm2_verifysignature --key-context=signkey.ctx \\\n  --hash-algorithm=sha256 \\\n  --message=tbs.bin \\\n  --scheme=rsassa \\\n  --signature=approved.sig \\\n  --ticket=verify.tkt\n```\nOn successful verification, the TPM will return a ticket, which is an HMAC-stamped\nproof<sup>12</sup>. We can then start a new policy session, satisfy the concrete policy (the one\nthat we created the signature over), then present the ticket, the `policyRef` and\nthe policy to authenticate the session.\n\n```\ntpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:15\ntpm2_policyauthorize --session=s.ctx \\\n  --input=approved.policy \\\n  --qualification=policyref.bin \\\n  --name=signkey.name \\\n  --ticket=verify.tkt\n```\nOn the `tpm2_policyauthorize` call, the TPM ensures your session digest equals the\nsigned `approved.policy`, checks the ticket is valid for the given `policyRef` and\nkey, then swaps the current session digest to the authorize policy (`authorized.policy`).\n\nAfterwards, the session digest matches the authorize policy we set during index definition, and the session is unlocked:\n\n```\ntpm2_nvextend 0x01000000 \\\n    --hierarchy=0x01000000 \\\n    --auth=session:s.ctx \\\n    --input=m1.bin\ntpm2_flushcontext s.ctx\ntpm2_flushcontext --transient-object\n```\nWith this construct, in case the PCR 15 measurement is changed by a future update, the index is not bricked, it doesn’t even need to be changed. The trusted key holder will just sign a new policy matching the new PCR 15 state and ship that new policy as part of the update. On the machine, the NV index with the same policy keeps working. This is exactly how systemd keeps TPM-based disk unlock working across kernel updates: every UKI ships fresh signatures matching its own expected PCR 11 state. But also keep the flip side of this mechanism in mind: if the key holder only ever signs a single state, the policy is only satisfiable while the machine is in exactly that state. We will exploit this in a moment.\n\n### `PolicyOR`\n\nA policy digest commits to one exact chain of assertions. All the elements are sequenced\nvia AND, we must match every element in order to get the expected session hash.\nSometimes we might want to construct an alternative branch instead, for example if we know\ntwo good states, both of which we want to allow, or two different ways to authorize the same\noperation. For this `PolicyOR` exists<sup>13</sup>. The TPM will check\nif the current session’s digest is a member of the allowed list, then replace it in a similar\nway it does when the `PolicyAuthorize` ticket is resolved. The policy is satisfied if one of\nits branches is satisfied.\n\n## Building a secure, policy-based NvPCR\n\nWith the previously introduced primitives, we can now take a look at how systemd\nconstructs a secure NvPCR. The NV index is protected by a write policy with two\nbranches that are connected with a `PolicyOR`: A `PolicyAuthorize` branch with a\npublic key and the `policyRef` `initrd`, and a `PolicyNvWritten(true)` branch.\n\nLet’s take a look at the `PolicyNvWritten(true)` branch first. This assertion allows\nchecking for the `written` attribute as part of a policy<sup>14</sup>. So `PolicyNvWritten(true)` can be\nsatisfied without further authorization if the NvPCR has already been extended before. This is\nthe branch that is used after the initial setup, during runtime. At that point, extending\nthe NvPCR doesn’t require additional authentication and can be done by any component with\naccess to the device.\n\nThe other branch, `PolicyAuthorize`, can be satisfied with a signed policy. This\nbranch must be used for the initial extend of the NvPCR. The concrete policy\nused by systemd is a PCR policy for PCR 11, matching the expected state of that PCR\nwhen running in the initrd during early boot. systemd tracks the boot phase in PCR 11:\nWhen the initrd is started, `systemd-pcrphase-initrd.service` measures the event\n`enter-initrd`. We construct the PCR policy against the expected state of PCR 11 after\nthis event. If the system hasn’t been tampered with up to that point and the signature\nis valid, the signed PCR policy is satisfied and the NvPCR can be initially extended.\nWhen progressing to the next boot phase, the same service measures the phase event\n`leave-initrd`. This locks the `PolicyAuthorize` branch for the rest\nof the system lifetime. The NvPCR can then only be extended via the `PolicyNvWritten`\nbranch.\n\nYou might wonder why the write policy takes the indirection via `PolicyAuthorize`\ninstead of embedding the PCR policy directly. On a real system, PCR 11 doesn’t only\ncontain the phase events: the UKI stub measures the kernel and initrd into it first,\nso the initrd state of PCR 11 changes with every update. Embedded directly, every\nupdate would change the write policy and with it the name of the index, and the NvPCR\nwould have to be recreated on every update. With `PolicyAuthorize`, the write policy\nand the name stay stable, and only the signature shipped with the UKI changes.\n\nLet’s construct this final NvPCR version, similar to how systemd does it.\nMeasure the event that marks the start of the initrd boot phase, as done by\n`systemd-pcrphase-initrd.service`:\n\n```\necho -n \"enter-initrd\" | tpm2_pcrevent 11\ntpm2_pcrread sha256:11\n```\n```\n  sha256:\n    11: 0xD15B0E8E244E65C40F024E95773F2347CE4EF3FFE6B597C9A14B50BBAB6DF319\n```\nLet’s author the write policy.\nThe key from the previous section is reused. It takes the role of the UKI’s PCR signing\nkey, whose public half is shipped in the `.pcrpkey` section of the UKI. First write the\n`policyRef` with the value `initrd`<sup>15</sup>. Then use a trial session to create the key-based\nbranch of the policy that must be used for the first write from within the initrd:\n\n```\nprintf 'initrd' > initrd.ref\ntpm2_startauthsession --session=trial.ctx\ntpm2_policyauthorize --session=trial.ctx \\\n  --name=signkey.name \\\n  --qualification=initrd.ref \\\n  --policy=init.branch\ntpm2_flushcontext trial.ctx\n```\nNext, create the second branch of the policy, the `PolicyNvWritten(true)`:\n\n```\ntpm2_startauthsession --session=trial.ctx\ntpm2_policynvwritten --session=trial.ctx --policy=written.branch s\ntpm2_flushcontext trial.ctx\n```\nAnd finally combine the two policies with a `PolicyOR`:\n\n```\ntpm2_startauthsession --session=trial.ctx\ntpm2_policyor --session=trial.ctx \\\n  --policy-list=sha256:init.branch,written.branch \\\n  --policy=write.policy\ntpm2_flushcontext trial.ctx\n```\nWe use that policy to define the NvPCR, nearly identical to how we did before:\n\n```\ntpm2_nvdefine 0x01D10200 \\\n    --hierarchy=o \\\n    --hash-algorithm=sha256 \\\n    --attributes=\"nt=extend|policywrite|ownerread|authread|clear_stclear\" \\\n    --policy=write.policy\ntpm2_nvreadpublic 0x01D10200\n```\n```\n0x1d10200:\n    name: 000bf2e615c91fc1738ee23d6906f3cc5da932ed3d3dd19f99de0d0e0839712d8ecf\n    ...\n    attributes:\n      friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear\n      value: 0x8060048\n    size: 32\n    authorization policy: A765636C5A04447BD790486F98DAF82CA694868DF19C1AF80632A52261E374EF\n```\nAs the write policy depends on your generated key, your authorization policy and the index\nname will again differ from mine. The only thing new here is the `clear_stclear` attribute:\nIt tells the TPM to clear the NV index on reboot<sup>16</sup>. Similar to PCRs, the NvPCR should reset\non reboot, not persist measurements of a previous boot to the next. The `written` attribute\nis also cleared on reset.\n\n`0x01D10200` is the real handle from the NV index range systemd uses. Which NvPCRs exist on a\nsystem is defined by small JSON files in `/usr/lib/nvpcr/*.nvpcr`, which since v262 must\nbe shipped as part of the UKI. systemd currently ships four definitions: `hardware` for the\nproduct UUID, `cryptsetup` for the LUKS unlock mechanism used, `verity` for the root hashes\nof activated verity volumes, and `login` for user logins<sup>17</sup>. All four record events that are\nunpredictable or unbounded in number, exactly the kind we don’t want in the real PCRs.\n\nThen we craft the concrete PCR policy for the expected initrd state of PCR 11\nand sign it together with the `policyRef`:\n\n```\ntpm2_startauthsession --session=trial.ctx\ntpm2_policypcr --session=trial.ctx --pcr-list=sha256:11 --policy=initrd.policy\ntpm2_flushcontext trial.ctx\ncat initrd.policy initrd.ref > tbs.bin\nopenssl dgst -sha256 -sign sign.key.pem -out initrd.sig tbs.bin\n```\nIn systemd, this is done at image build time with ukify. The tool gained a new flag `--sign-initrd-pcrs`\nthat precomputes the expected PCR 11 value for the `enter-initrd` phase and embeds the signed policy in the\nUKI into a `.pcrsig` section.\n\nOn each boot, systemd’s `systemd-tpm2-setup-early.service` uses the signature and the\nauthorize policy path to initialize the NvPCR in the initrd. First, we let the TPM\nverify the signature:\n\n```\ntpm2_verifysignature --key-context=signkey.ctx \\\n  --hash-algorithm=sha256 --message=tbs.bin --scheme=rsassa \\\n  --signature=initrd.sig --ticket=initrd.tkt\n```\nThen we start a policy session. We satisfy the PCR policy with the current state of PCR 11,\nthen call `tpm2_policyauthorize` to present the signature over the `initrd.policy`.\nFinally, we call `tpm2_policyor` to present both branches and match the expected auth digest of\nour NvPCR:\n\n```\ntpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:11\ntpm2_policyauthorize --session=s.ctx --input=initrd.policy \\\n  --qualification=initrd.ref --name=signkey.name --ticket=initrd.tkt\ntpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch\n```\nWith this session state, the policy is satisfied and we extend the NvPCR. systemd initializes\nNvPCRs with all-zeros. The written value doesn’t matter, the point is that the index gains the\n`written` attribute, and that this first extend happened under the signed policy.\n\n```\nhead -c 32 /dev/zero > zero.bin\ntpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=zero.bin\ntpm2_flushcontext s.ctx\ntpm2_flushcontext --transient-object\ntpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64\n```\n```\nf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b\n```\nBecause the extended value is all-zeros, every freshly initialized SHA-256 NvPCR starts\nout at this exact value, `SHA256(zeros32 || zeros32)`, on every machine. With the first\nwrite done, the index gained the `written` attribute and thereby a new name:\n\n```\ntpm2_nvreadpublic 0x01D10200\n```\n```\n0x1d10200:\n    name: 000bf08c214c037f85d3520549dba23471a88af8aab1913bf269edfbb44b6d2de9f5\n    ...\n    attributes:\n      friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear|written\n      value: 0x28060048\n```\nKeep this name in mind, we will get back to it in the next section.\n\nAt the end of the initrd boot phase, systemd will measure the event marking the end of that phase:\n\n```\necho -n \"leave-initrd\" | tpm2_pcrevent 11\n```\nWith this, the signed policy cannot be satisfied anymore until the next reboot!\n\nLater at runtime, the write is authorized solely on the `written` property the NvPCR gained during\nits initialization in initrd. This is what `systemd-pcrextend` does when it records an event, and\nit requires no key and no secret, any component with access to the TPM can append. That is\ndeliberate: extends of real PCRs are unauthenticated, too, because adding history is harmless,\nonly rewriting it must be impossible.\n\n```\ntpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policynvwritten --session=s.ctx s\ntpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch\ntpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=m1.bin\ntpm2_flushcontext s.ctx\ntpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64\n```\n```\n5bbe1770fd46c5edfde5ef444b2d681d87925fcd71b1fe98c4f65749a6f3afb6\n```\nNotice that we still need to present both branches to the session, including the `init.branch`\ndigest. For that reason systemd persists it to `/run/systemd/nvpcr/<name>.auth` after\ninitialization.\n\nLike for regular PCRs, systemd records each NvPCR measurement in its userspace\nmeasurement log (`/run/log/systemd/tpm2-measure.log`), so that a verifier can later\nreplay the events and interpret the NvPCR value.\n\nThis concludes the hands-on part. When you are done experimenting, shut the software TPM down:\n\n```\nkill \"$(cat swtpm.pid)\"\n```\n## Security considerations of the NvPCR design\n\nLet’s take a look at the security this construction is providing us with. The attacker\nwe are looking at gains access during runtime of the system, after the `leave-initrd`\nevent is measured, and wants to rewrite the NvPCR history unnoticed, like Alice hiding\nher login in that naive index attack from the beginning.\nThey have root privilege and can access the TPM to read, write, undefine\nand redefine NV indexes, extend (but not reset) PCRs. They can also reboot or boot a\ndifferent OS. Attacks against the TPM hardware itself are out of scope.\n\nThe TPM itself is trusted, and so is the measured boot chain up to and including the initrd, including firmware, bootloader and UKI. The initrd is authorized to initialize NvPCRs by design, which is expressed by the signed policy over the initrd state of PCR 11. But PCR 11 is under OS control, a different kernel could extend the expected values and reach the authorized state, too. It takes the firmware-controlled PCRs, which record the bootloader and UKI that actually ran, to tie the initrd state of PCR 11 to the initrd we trust. Booting anything else is visible to the verifier. We also have to trust the key holder, as whoever controls the PCR signing key can bless arbitrary states, and the verifier, which has to check more than just the NvPCR values, as we will see below.\n\nThe write policy and the public key are no secrets, so the attacker can undefine our\nNvPCR and redefine it, byte-for-byte identical. To replay a history, the attacker then\nhas to perform the first write to the fresh index, and both branches of the write\npolicy refuse: The `PolicyNvWritten(true)` branch can’t authorize the write. Nothing\nstops the attacker from running the policy commands, and the session digest will even\nmatch the write policy, but the deferred written-check fails against the never-written\nindex, and the TPM rejects the extend. And the `PolicyAuthorize` branch fails, too: the only\nsignature in existence approves the initrd state of PCR 11, which the machine left\nwhen `leave-initrd` was measured. The session can’t reach the signed digest anymore,\nso the TPM rejects and the attacker isn’t able to create a forged history.\n\nThere is one more signature the attacker might try: the same key also signs the UKI’s\nother PCR policies, for example the ones used for disk unlock, and some of those are\nsatisfiable in the runtime state of the machine. This is where the\n`policyRef` comes in: our authorize branch only accepts signatures made for the ref\n`initrd`, and the other policies are signed with a different ref (or none at all).\nThe signatures are not interchangeable.\n\nOf course, the attacker doesn’t have to reuse our write policy. They can redefine the\nindex with `authwrite`, like our naive index from the beginning, and replay whatever\nhistory they like. But remember that the name of an NV index is a hash over all of its\npublic properties: the attributes and the write policy, which in turn commits to the\nvendor’s public key. There is no way to create an index that is writable outside the\ninitrd without ending up with a different name.\n\nThis makes the name the anchor of the whole design, and the last missing piece is\nmaking it verifiable. After initializing an NvPCR, systemd extends the event\n`nvpcr-init:<name>:0x<handle>:<tpm-name>` into PCR 9, a real, non-resettable PCR.\nIt is the cheapest PCR to sacrifice: the kernel measures into it every initrd it is\nhanded, which on a UKI boot is a concatenation of the UKI’s embedded initrd with cpio\narchives that systemd-stub generates on the fly, for example for credentials, all mangled\ninto a single value, on some setups joined by verbose bootloader records. This makes\nPCR 9 hard to predict, so no unlock policy can bind to it anyway, while the measurement\nthat matters, the UKI initrd, is already cleanly covered by PCR 11.\nOnce all NvPCRs are initialized, `systemd-pcrnvdone.service` measures a separator\nevent `nvpcr-separator` into PCR 9, still inside the initrd. The NvPCR values themselves\nare attested with `TPM2_NV_Certify`, which has the TPM sign the current index contents\ntogether with the index name. The new systemd-report-sign-tpm2 signer emits these\nattestations alongside regular PCR quotes. A verifier doing remote\nattestation must only trust NvPCR values whose attested index name matches an\n`nvpcr-init` event that appears before the separator in the PCR 9 event log. Any\nrecreated index fails this check: wrong name, or right name but logged after the\nseparator.\n\nTwo capabilities of our attacker are left: rebooting and booting a different OS. A reboot resets the NvPCRs just like the real PCRs, and the next boot re-initializes them in the initrd. The attacker gains nothing: the new boot is a genuine one, its history starts fresh by design, and the verifier can see that a reboot happened. Booting a different OS doesn’t help either, as it produces different measurements in PCR 11 and the other boot chain PCRs. The signed policy can’t be satisfied, and any quote produced from such a boot exposes the manipulated boot chain.\n\n## Conclusion\n\nAn NvPCR in systemd v262 is an NV extend index whose first write is gated by a signed PCR policy that can only be satisfied in the initrd, while all later writes are free. The index name, measured into PCR 9 during early boot, anchors the construction for verifiers: any index recreated with a weaker write policy carries a different name and is detected. We reconstructed such an NvPCR on the command line and discussed why an attacker can’t forge measurements: once the initrd window has closed, no road into the write policy of a fresh index remains, and everything the attacker can still do is destructive and shows up in attestation.\n\nA practical note to close with: The write policy is based on the UKI’s PCR signing key. When that key rotates, systemd-tpm2-setup detects the name mismatch, checks that the existing index looks like an NvPCR, and recreates it with the new policy. The same path automatically upgrades NvPCRs created by the pre-v262 design, so the roll-out to your systems will happen automatically with the systemd update.\n\nThe initrd-bound signed policy turns out to be useful beyond NvPCRs, too. With\n`systemd-cryptenroll --tpm2-public-key-policyref=initrd`, you can enroll LUKS keyslots\nthat can only be unlocked from the initrd. And if you want to see all of this on a real\nsystem, boot a v262 image with a UKI built with `--sign-initrd-pcrs` and take a look at\n`systemd-analyze nvpcrs` and the PCR 9 event log.\n\n## Thanks\n\n*To my colleague at Amutable Chris Coulson, who authored the new NvPCR design and gave insightful\ncorrections and additions to this blog post. Linux security work in the upstream projects\nwe all rely on is at the heart of our mission at Amutable: building new secure foundations.*\n\n## References\n\n- Add support for nvindex-based additional PCRs for TPM2, aka “NvPCRs” PR on systemd\n- tpm2: Improve how NvPCR protection works PR on systemd\n- TPM2 PCR Measurements Made by systemd documentation\n- TPM 2.0 Library (Section “Latest Version”)\n- tpm2-tools man pages\n\n1. Dynamic Root of Trust for Measurements is a mechanism to establish a new, verifiable chain of trust at runtime, usually supported by the platform through something like Intel TXT or AMD SVM. ↩︎\n2. The UAPI.7 spec documents how firmware and OS PCRs are commonly used. ↩︎\n3. See Section “34.2 NV Indices” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎\n4. Section “13 Names” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture lists the name equations for all entity types (Table 9), and explicitly describes how the name of an NV index changes when the `written` attribute is set. ↩︎\n5. Hierarchies and their authorizations are introduced in Section “10 TPM Control Domains” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture in case you want to dive into it, but not super important for what we are doing here. ↩︎\n6. Notice the `nt=0x1` in the output is a bug in tpm2-tools.`TPM_NT_EXTEND` is 0x4 according\nto spec, and the raw value correctly contains that. I’ve opened an upstream PR to fix this. ↩︎\n7. Notice that the `TPM2_NV_Extend` call used by tpm2_nvextend uses the arbitrary bytes provided\ndirectly in the hash function without pre-hashing:`NV := H_nameAlg(NV_old ‖ input)` .`TPM2_PCR_Extend` on the other hand requires a fixed size digest to be passed\n(`PCR := H(PCR ‖ digest_in)` ), and`TPM2_PCR_Event` does hashing of the handed event\nitself (`PCR := H(PCR ‖ H(event))` ). ↩︎\n8. Policies are specified in Section “16.7 Enhanced Authorization” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, which is a surprisingly readable introduction to the topic. Trial sessions are covered in Section “16.7.10 Trial Policy”. ↩︎\n9. Reproducing the hash with some constants from the spec: `H(zeros32 || 0000017f || 00000001000b03008000 || H(pcr15_value))`On the terminal: ↩︎```\npcrdigest=$(head -c 32 /dev/zero | sha256sum | cut -d' ' -f1)\nprintf '%064d0000017f00000001000b03008000%s' 0 \"$pcrdigest\" \\\n    | xxd -r -p | sha256sum\n```\n10. `PolicyPCR` can be immediate and deferred at the same time, depending on its\nparameters: the caller may pass the PCR digest they expect, which the TPM then\nchecks against the selected PCRs right away. Called without a digest, like\ntpm2_policypcr does here, the TPM just folds the current PCR values into the`policyDigest` , and whether they were the right ones only shows in the final\ncomparison against`authPolicy` . In both variants, the TPM additionally records\nthe current PCR update counter in the session as a deferred constraint: if PCRs\nare updated after the assertion, the session can no longer authorize anything. ↩︎\n11. The spec discusses the problem of “brittleness” in Section “16.7.11 Modification of Policies” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, and introduces a similar construction as we are about to build. ↩︎\n12. Tickets are HMACs keyed with an internal TPM proof value, enabling the TPM to re-verify a signature later without loading the asymmetric key again, see “8.4.6.3 Tickets” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎\n13. The digest replacement is illustrated in Section “16.7.4 Policy OR” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture. ↩︎\n14. While the policy commands run, the session isn’t tied to any NV index yet, so the written property can’t be checked immediately. `TPM2_PolicyNvWritten` extends\nthe`policyDigest` like any other assertion, but additionally records the\nclaimed written state in the session, as a deferred check. Only when the session is\nused to authorize a command does the TPM compare it against the actual attribute of\nthe accessed index, and reject the command on mismatch. ↩︎\n15. The real systemd implementation doesn’t use the raw string as `policyRef` , but`SHA256(\"initrd\")` , as the size of a policy ref is limited. The semantics are the same. ↩︎\n16. More precisely, `clear_stclear` means the index is cleared on any`TPM2_Startup(CLEAR)` ,\nwhich the platform issues on a TPM reset or TPM restart. Besides the reboot, that also\ncovers resume from hibernation (not that Linux systems really hibernate with secure\nboot enabled). Resume from suspend on the other hand corresponds to`TPM2_Startup(STATE)` ,\na TPM resume, which preserves the index. ↩︎\n17. If you inspect these indices on a real system, all but `hardware` will show one more\nattribute:`orderly` . An orderly NV index is backed by TPM RAM and only flushed to\nNVRAM on clean shutdown, sparing the NVRAM from wear on frequently written indices\nlike`login` . TPM RAM is even scarcer than NVRAM, though, so the`hardware` NvPCR,\nwritten only once per boot, isn’t using it. And as`orderly` is an attribute, it is part\nof the index name. ↩︎","body_html":"<p>*systemd answers TPM PCR scarcity with additional PCR-like registers allocated in the TPM’s NV memory,\nwith an anchoring design that was reworked in v262.\nIn this hands-on deep dive, we rebuild a systemd NvPCR from scratch against a software TPM,\npicking up the required TPM concepts along the way, and analyze why the design is secure.*</p>\n<h2 id=\"why-can-t-systemd-get-enough-of-those-pcrs\">Why can’t systemd get enough of those PCRs?</h2>\n<p>Many of systemd’s security features rely on TPM PCR measurements: Passwordless full disk encryption can unlock disks automatically if the PCR measurements are as expected, preventing credential theft while still allowing unattended reboots of remote machines with encrypted root disk. Service credentials can be encrypted against the expected PCR state. Boot-phase bound credentials are also supported, allowing secrets that can only ever be decrypted in the initrd. And with remote attestation, a machine can prove to another party what it booted and what happened since, by having the TPM sign its current PCR state (a so-called quote). All of this is built on PCR measurements.</p>\n<p>TPM PCRs are scarce. On common standard-compliant TPMs there are only 24 PCRs available.\nThe lower PCR indices 0-7 are owned by the firmware and used for UEFI boot measurements.\n16 is a debug PCR that can be reset and is therefore unusable, 17-22 are reserved for\nDynamic Root of Trust for Measurements&lt;sup&gt;1&lt;/sup&gt;, and 23 is reserved for application support.\nSo only 8-15 are left for systemd to do all OS-related measurements&lt;sup&gt;2&lt;/sup&gt;.</p>\n<p>From a remote attestation perspective, a single measurement register can be enough to verify a system. We can use the measurement log to replay the events and interpret what was measured to land on the final value we observe. But PCRs are not only read by remote verifiers, they are also what local secrets are locked against, and that use needs predictable values: every event flowing into such a PCR must be known in advance, otherwise the policy breaks. Some measurements are inherently unpredictable, depending for example on the closed source vendor firmware of the individual platform, or on the behavior of users. We might want to include a login event in our remote attestation quote, but the root disk should still be able to unlock itself after someone logged in. So the noisy and unpredictable event types need registers of their own: out of the way of the PCRs that locks depend on, but still measured and attestable. And that’s where systemd was quickly hitting a hard limit: with only eight PCR slots available to the OS, there isn’t much space to give event types their own register.</p>\n<p>This is why systemd introduced NvPCRs in v259: additional PCR-like registers, allocated in the TPM’s non-volatile memory, hence the name. They host the event types we don’t want in the real PCRs, and their values are consumed through remote attestation. In v262, the anchoring of NvPCRs was reworked to increase their security. Previously, the anchoring was based on a random secret sealed against PCR 11 and stored on disk, which an attacker could recover by booting a different OS that replays the expected PCR 11 values, or simply replace with a secret they know. This blog post describes the reworked design that ships in v262: how it works, and why it is secure.</p>\n<h2 id=\"setup-for-follow-along\">Setup for follow-along</h2>\n<p>We’ll check out the basic working principles and get a feeling for the matter by constructing our own NvPCRs against a software TPM on the command line. If you’d rather just read, that’s fine, too, I’ll provide all important output.</p>\n<p>As a prerequisite, install the required tools, for example with nix or dnf:</p>\n<pre><code>nix shell nixpkgs#{tpm2-tools,xxd,swtpm,openssl}</code></pre>\n<pre><code>dnf install tpm2-tools vim-common swtpm openssl</code></pre>\n<p>Create a directory you want to work in and start the software TPM:</p>\n<pre><code>mkdir state\nswtpm socket \\\n  --tpm2 \\\n  --tpmstate &quot;dir=$PWD/state&quot; \\\n  --ctrl &quot;type=tcp,port=2322&quot; \\\n  --server &quot;type=tcp,port=2321&quot; \\\n  --flags startup-clear \\\n  --pid &quot;file=$PWD/swtpm.pid&quot; \\\n  -d</code></pre>\n<p>Export the connection details so tpm2-tools knows where to find the TPM:</p>\n<pre><code>export TPM2TOOLS_TCTI=&quot;swtpm:host=127.0.0.1,port=2321&quot;</code></pre>\n<p>Check the TPM is working by reading the PCRs:</p>\n<pre><code>tpm2_pcrread sha256</code></pre>\n<p>This should show the PCRs 0-16 being all zero. If not, you might be talking to the TPM of your\nplatform and not the software TPM, check again that you exported <code>TPM2TOOLS_TCTI</code> correctly.\nThis is important, as we don’t want the following experiments to mess with the sealed\nsecrets of your platform.</p>\n<h2 id=\"tpm-nv-index-as-pcr-replacement\">TPM NV index as PCR replacement</h2>\n<p>A TPM NV index&lt;sup&gt;3&lt;/sup&gt; is a non-volatile storage slot identified by a unique name. NV indices\npersist across reboots and can hold user defined data: an opaque value, a counter, bitfield\nor similar. The properties of an NV index define how it behaves and what it can be used for:\nits handle, the size of the stored data, a set of attributes controlling how the index can be\nmanipulated or read, and an authorization policy and authorization value (the latter being the\nonly non-public property) that optionally specify under which conditions the index can be\nmanipulated. Each index has a <em>nameAlg</em>, the hash algorithm used to compute the unique name\nfrom the public properties of the index&lt;sup&gt;4&lt;/sup&gt; as</p>\n<p><code>Name = nameAlg || H_nameAlg(marshal(TPMS_NV_PUBLIC))</code>.</p>\n<p>Let’s create a PCR-like NV index! We are using tpm2_nvdefine from tpm2-tools for this.\n<code>0x01000000</code> is the handle for the index that we are defining (somewhat randomly picked).\nThe <code>--hierarchy=o</code> flag selects the authorization we define the index under: NV indexes\nlike ours live in the TPM’s owner hierarchy, and defining or undefining them requires the\nowner authorization value&lt;sup&gt;5&lt;/sup&gt;. On a typical Linux system that value is empty, so effectively\nanyone with access to the TPM device, usually root, holds owner authorization.\nThe <code>hash-algorithm</code> flag corresponds to the previously named nameAlg and is chosen as sha256.\nThen we select the attributes for our NV index: <code>authread|authwrite</code>, in combination with an\nempty authorization value, allows anyone with access to the device to read and write the\nindex. And <code>nt=extend</code> says we want this to be extendable like a PCR.</p>\n<pre><code>tpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=&quot;nt=extend|authread|authwrite&quot;</code></pre>\n<p>Take a look at the result using the following command:</p>\n<pre><code>tpm2_nvreadpublic</code></pre>\n<pre><code>0x1000000:\n  name: 000be9606b61ec27bc8deec096dd38a6f8961cb8b3ef2fe879b27de704ab2f3d44e3\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: authwrite|nt=0x1|authread\n    value: 0x40044\n  size: 32</code></pre>\n<p>We can see the properties we configured&lt;sup&gt;6&lt;/sup&gt;, the size, and the name that is a hash over\nthe public properties. As you defined the exact same properties as I did, you will get\nexactly the same hash as index name.</p>\n<p>Now we can use our NV index like a proper PCR and extend it with a measurement, for example with a login event of user Alice:</p>\n<pre><code>printf &#39;user-alice-logged-in&#39; &gt; m1.bin\ntpm2_nvextend 0x01000000 --input=m1.bin</code></pre>\n<p>Then read its value:</p>\n<pre><code>tpm2_nvread 0x01000000 --size=32 | xxd -p -c 64</code></pre>\n<pre><code>bd9927a653c6c33297b7d884a8ad99df0f5b5b1c1e1e86f762b3aced8bc77f50</code></pre>\n<p>If we now inspect the NV index again, we can observe something interesting:\nThe index got a new attribute <code>written</code>, indicating that the index has been written\none or more times. As the set of attributes was updated, and the name of the index\nis a hash including the attributes, it got a new name too! We will make use of this\nlater.</p>\n<pre><code>tpm2_nvreadpublic</code></pre>\n<pre><code>0x1000000:\n  name: 000b1191942a636c11a57571f0d435c290a1955788229305ce0aa8a2f393e9ed770c\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: authwrite|nt=0x1|authread|written\n    value: 0x20040044\n  size: 32</code></pre>\n<p>You can do another measurement if you like, measuring the login event of Bob:</p>\n<pre><code>printf &#39;user-bob-logged-in&#39; &gt; m2.bin\ntpm2_nvextend 0x01000000 --input=m2.bin\ntpm2_nvread 0x01000000 --size=32 | xxd -p -c 64</code></pre>\n<pre><code>e758d6e2e54620fd45b9cd1fd57cbba62757f12751d549fd2fc957ed1908ed29</code></pre>\n<p>At this point, the value of the NV index is <code>HASH(HASH(0x0 || m1) || m2)</code>&lt;sup&gt;7&lt;/sup&gt;.\nThe index name didn’t change again with the second measurement.</p>\n<p>So we have an NV index that is extendable in the same way a PCR is. Can we already use it as PCR replacement? We can’t. We are missing a fundamental property of the real PCRs: To use the measurement chain as proof for anything, it must not be resettable or replayable during the runtime of the system! Otherwise an attacker that gained access to the system could just reset the measurement history and replay the history of an unmanipulated system, making the attack undiscoverable through remote attestation.</p>\n<p>Sadly, this isn’t a property the TPM grants us for our PCR-like NV index: We defined the index at system runtime, and we can undefine it again:</p>\n<pre><code>tpm2_nvundefine 0x01000000 --hierarchy=o\ntpm2_nvreadpublic</code></pre>\n<p>Given the two example measurements we did in this section, if Alice has malicious intentions and gains root access, and with it owner authorization, they can just undefine and redefine the index, then replay a history where Alice never logged in. The redefined index gets the exact same name, and we couldn’t notice.</p>\n<p>What we need is an index that anyone can extend, but that nobody can restart during the runtime of the system. Policies are the TPM’s tool to express such conditions.</p>\n<h2 id=\"exploring-policies\">Exploring policies</h2>\n<p>A very powerful concept the TPM interface provides is the policy&lt;sup&gt;8&lt;/sup&gt;.\nSuch a policy is an opaque digest stored with the object it protects, in the <code>authPolicy</code>\nproperty we already saw in the public properties of our NV index.\nTo fulfill a policy, we request a policy session from the TPM. Each session has its own\ncontext, containing a digest called <code>policyDigest</code> and a set of constraints that can be\nmodified by executing policy assertions. A fresh session has a <code>policyDigest</code> that is all zero.\nWithin a session, we can then run different policy commands against the TPM, each\nasserting some condition. Some assertions are checked immediately while the command\nexecutes. Others are deferred: the command records a constraint in the session context,\nand the TPM only checks it once the session is used for authorization. Either way, each\ncommand extends the session’s digest, using the following logic:</p>\n<p><code>policyDigest := H(policyDigest_old || commandCode || command-specific args)</code></p>\n<p>That extend scheme works exactly like a PCR, even though the <code>policyDigest</code> is not backed by a real PCR.\nA caller can present different types of evidence, each extending the <code>policyDigest</code>.\nIf the assertions add up so that the session’s <code>policyDigest</code> matches the <code>authPolicy</code>\nof the index, the session is authorized and the desired command can be called with it.\nThe author of a policy computes the expected <code>authPolicy</code> digest via a trial session\nin a trusted environment or through pre-calculation offline. A trial session runs the\nsame digest calculation, but doesn’t verify any of the conditions, and in exchange can’t\nbe used to authorize anything. This is what allows an author to compute a policy for a\nstate the machine currently isn’t in.</p>\n<h3 id=\"policypcr\"><code>PolicyPCR</code></h3>\n<p>The cool thing is that policies can make permission depend on the machine state by\nlocking against the expected value of a PCR. Let’s create such a policy!\nFirst, we start a trial session to define the policy we want to set on our NV index.\nThis is done by invoking <code>tpm2_startauthsession</code> without the <code>--policy-session</code> flag.</p>\n<pre><code>tpm2_startauthsession --session=trial.ctx</code></pre>\n<p>We then call <code>tpm2_policypcr</code> to create an assertion over the currently observed\nPCR value(s) we select via <code>--pcr-list=</code>. For our experiment, we use PCR 15, one of\nthe OS-owned PCRs. The application PCR 23 might look like the natural playground, but\nlike the debug PCR 16 it can be reset at runtime, which is exactly the property we are\ntrying to get rid of:</p>\n<pre><code>tpm2_policypcr --session=trial.ctx \\\n  --pcr-list=sha256:15 \\\n  --policy=pcr15.policy</code></pre>\n<pre><code>7e247a603cd1052cabc095741b8ee2f7458aabeee960b8ec97d7f090171a039a</code></pre>\n<p>The resulting policy digest is printed and written to <code>pcr15.policy</code>&lt;sup&gt;9&lt;/sup&gt;.\nThen end the session:</p>\n<pre><code>tpm2_flushcontext trial.ctx</code></pre>\n<p>Let’s recreate the NV index from before, this time we protect write access to it with the policy we just created:</p>\n<pre><code>tpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=&quot;nt=extend|authread|policywrite&quot; \\\n  --policy=pcr15.policy</code></pre>\n<p>Notice we added both the <code>--policy=</code> flag and the <code>policyWrite</code> attribute.\nWe leave the <code>authRead</code> untouched. There is a <code>policyRead</code> too, but usually\nrestricting who can extend the index is much more interesting.</p>\n<pre><code>tpm2_nvreadpublic</code></pre>\n<pre><code>0x1000000:\n  name: 000b14a6915e5830ff2b83ebc87cdf5ac481fb29637c246489bdab1bba19948bb729\n  hash algorithm:\n    friendly: sha256\n    value: 0xB\n  attributes:\n    friendly: policywrite|nt=0x1|authread\n    value: 0x40048\n  size: 32\n  authorization policy: 7E247A603CD1052CABC095741B8EE2F7458AABEEE960B8EC97D7F090171A039A</code></pre>\n<p>Just trying to extend as before will now fail with an authorization error:</p>\n<pre><code>tpm2_nvextend 0x01000000 --input=m1.bin</code></pre>\n<pre><code>ERROR: Esys_NV_Extend(0x12F) - tpm:error(2.0): authValue or authPolicy is not\navailable for selected entity</code></pre>\n<p>Instead, to write to the index, we need to start a policy session and satisfy the policy by\npresenting the current PCR 15 state&lt;sup&gt;10&lt;/sup&gt;. After that, we can do the extension, presenting the session\nas authorization. And remember to flush the session context at the end.</p>\n<pre><code>tpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:15\ntpm2_nvextend 0x01000000 \\\n  --hierarchy=0x01000000 \\\n  --auth=session:s.ctx \\\n  --input=m1.bin\ntpm2_flushcontext s.ctx</code></pre>\n<p>If PCR 15 advances, the policy can’t be fulfilled anymore. Run the following command to extend the PCR:</p>\n<pre><code>echo something | tpm2_pcrevent 15</code></pre>\n<p>Now retry the three steps from before that unlocked the session based on the PCR.\nThe extend will fail with <code>tpm:session(1):a policy check failed</code>, as the PCR advanced and\nneither its current value (nor any of its future values!) matches the one included in the\npolicy anymore. The access to the PCR-bound index expired and can’t be regained during the\nruntime of the machine.</p>\n<p>Finally, undefine the index again with:</p>\n<pre><code>tpm2_nvundefine 0x01000000 --hierarchy=o</code></pre>\n<h3 id=\"policyauthorize\"><code>PolicyAuthorize</code></h3>\n<p>Locking against a PCR with <code>PolicyPCR</code> is super cool, but it is also brittle&lt;sup&gt;11&lt;/sup&gt;: At the\nend of the previous section, a measurement changed, and our access expired with no way\nto regain it. That is a problem, because PCR values change for legitimate reasons all\nthe time. Think of the passwordless disk unlock from the intro: the disk key is sealed\nagainst the PCR state of the boot chain, and the next kernel update changes exactly\nthat state. The disk wouldn’t unlock anymore, even though nothing bad happened, and we\ncertainly don’t want to re-encrypt the disk on every update.</p>\n<p>What would be nice to have instead is a policy that stays stable while the approved\nstate can change. Luckily, there is another mechanism that can be used to create a policy:\n<code>PolicyAuthorize</code>. It introduces a level of indirection and delegation, allowing us to create a\npolicy from a public key instead of a system state. To authorize, you satisfy another, concrete\npolicy (like a <code>PolicyPCR</code>), then present a signature over the expected policy digest and a\n<code>policyRef</code>. The concrete policy digest itself is not part of the policy. Whoever owns the\nkey can approve new concrete policies, offline. The <code>policyRef</code> scopes the signatures\nso the signed policy can’t be used out of context.</p>\n<p>Let’s create a RSA key pair to explore <code>PolicyAuthorize</code>:</p>\n<pre><code>openssl genrsa -out sign.key.pem 2048\nopenssl rsa -in sign.key.pem -pubout -out sign.pub.pem</code></pre>\n<p>Load the public key into the TPM so we can use it as part of a policy:</p>\n<pre><code>tpm2_loadexternal \\\n  --hierarchy=o \\\n  --key-algorithm=rsa \\\n  --public=sign.pub.pem \\\n  --key-context=signkey.ctx \\\n  --name=signkey.name</code></pre>\n<pre><code>name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661</code></pre>\n<p>The printed name is what will bind our policy to this key. And it is computed just like\nthe NV index names we saw earlier: as a digest over the object’s public properties, which for\na key includes the public key itself. <code>tpm2_readpublic</code> shows these public properties:</p>\n<pre><code>tpm2_readpublic --object-context=signkey.ctx</code></pre>\n<pre><code>name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661\nqualified name: 000b028928264dd3d32fc9c10072d8f3e286ce20ef02c0873ecca601c3d4534b7661\nname-alg:\n  value: sha256\n  raw: 0xb\nattributes:\n  value: userwithauth|decrypt|sign\n  raw: 0x60040\ntype:\n  value: rsa\n  raw: 0x1\nexponent: 65537\nbits: 2048\n...\nrsa: ...</code></pre>\n<p>Besides the name algorithm, the object attributes and the key parameters, the public area contains the raw RSA modulus (shortened here). Any change to the public key changes the name, and with it any policy created from that name.</p>\n<p>Now use another trial session and the key name to create a policy that can be authorized using this key:</p>\n<pre><code>printf &#39;demo&#39; &gt; policyref.bin\ntpm2_startauthsession --session=trial.ctx\ntpm2_policyauthorize --session=trial.ctx \\\n  --name=signkey.name \\\n  --qualification=policyref.bin \\\n  --policy=authorized.policy\ntpm2_flushcontext trial.ctx\ntpm2_flushcontext --transient-object</code></pre>\n<pre><code>6120c875c3afb0b2811fc7c3c045c32fb53596e8009e96855ed4aa6c332d0bfb</code></pre>\n<p>The resulting policy digest contains no PCR value at all, it only depends on the\nkey name (and through it on the public key) and the <code>policyRef</code> label. As you\ngenerated a different key pair, the policy hash you will get will differ, too.\nNotice the second flush invocation with <code>--transient-object</code>: it removes the\nkey object that <code>tpm2_loadexternal</code> left behind in the TPM’s limited\ntransient memory. tpm2-tools doesn’t flush what it loads, so we\nwill repeat this cleanup after commands that load keys.\nThe printed policy hash is written to <code>authorized.policy</code>,\nwhich we can then use to define our NV index again, similar to how we did it before:</p>\n<pre><code>tpm2_nvdefine 0x01000000 \\\n  --hierarchy=o \\\n  --hash-algorithm=sha256 \\\n  --attributes=&quot;nt=extend|authread|policywrite&quot; \\\n  --policy=authorized.policy</code></pre>\n<p>For now, nobody can write this index, because no signature satisfying the policy exists yet.</p>\n<p>In practice, this flow will usually have two parties: the key holder is for example\na team of distro maintainers. The individual machine is the other party that presents\nevidence to its TPM. The key holder computes a concrete policy digest to approve,\nfor example a <code>PolicyPCR</code> for a specific build, then creates a signature over that digest\nand the <code>policyRef</code>.</p>\n<p>Create a new PCR policy for the updated value of PCR 15, that’s what we want to bless now. Like in the previous section we run a trial session to get a policy digest for the observed state:</p>\n<pre><code>tpm2_startauthsession --session=trial.ctx\ntpm2_policypcr --session=trial.ctx \\\n  --pcr-list=sha256:15 \\\n  --policy=approved.policy\ntpm2_flushcontext trial.ctx</code></pre>\n<p>Concatenate the policy and the policyRef:</p>\n<pre><code>cat approved.policy policyref.bin &gt; tbs.bin</code></pre>\n<p>Then sign the whole thing with the private key:</p>\n<pre><code>openssl dgst -sha256 -sign sign.key.pem -out approved.sig tbs.bin</code></pre>\n<p>The policy and its signature can then be shipped to a machine, for example as part of an image or update. This is the artifact showing the key holder approved this concrete policy.</p>\n<p>On the machine, we let the TPM verify the signature first:</p>\n<pre><code>tpm2_verifysignature --key-context=signkey.ctx \\\n  --hash-algorithm=sha256 \\\n  --message=tbs.bin \\\n  --scheme=rsassa \\\n  --signature=approved.sig \\\n  --ticket=verify.tkt</code></pre>\n<p>On successful verification, the TPM will return a ticket, which is an HMAC-stamped\nproof&lt;sup&gt;12&lt;/sup&gt;. We can then start a new policy session, satisfy the concrete policy (the one\nthat we created the signature over), then present the ticket, the <code>policyRef</code> and\nthe policy to authenticate the session.</p>\n<pre><code>tpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:15\ntpm2_policyauthorize --session=s.ctx \\\n  --input=approved.policy \\\n  --qualification=policyref.bin \\\n  --name=signkey.name \\\n  --ticket=verify.tkt</code></pre>\n<p>On the <code>tpm2_policyauthorize</code> call, the TPM ensures your session digest equals the\nsigned <code>approved.policy</code>, checks the ticket is valid for the given <code>policyRef</code> and\nkey, then swaps the current session digest to the authorize policy (<code>authorized.policy</code>).</p>\n<p>Afterwards, the session digest matches the authorize policy we set during index definition, and the session is unlocked:</p>\n<pre><code>tpm2_nvextend 0x01000000 \\\n    --hierarchy=0x01000000 \\\n    --auth=session:s.ctx \\\n    --input=m1.bin\ntpm2_flushcontext s.ctx\ntpm2_flushcontext --transient-object</code></pre>\n<p>With this construct, in case the PCR 15 measurement is changed by a future update, the index is not bricked, it doesn’t even need to be changed. The trusted key holder will just sign a new policy matching the new PCR 15 state and ship that new policy as part of the update. On the machine, the NV index with the same policy keeps working. This is exactly how systemd keeps TPM-based disk unlock working across kernel updates: every UKI ships fresh signatures matching its own expected PCR 11 state. But also keep the flip side of this mechanism in mind: if the key holder only ever signs a single state, the policy is only satisfiable while the machine is in exactly that state. We will exploit this in a moment.</p>\n<h3 id=\"policyor\"><code>PolicyOR</code></h3>\n<p>A policy digest commits to one exact chain of assertions. All the elements are sequenced\nvia AND, we must match every element in order to get the expected session hash.\nSometimes we might want to construct an alternative branch instead, for example if we know\ntwo good states, both of which we want to allow, or two different ways to authorize the same\noperation. For this <code>PolicyOR</code> exists&lt;sup&gt;13&lt;/sup&gt;. The TPM will check\nif the current session’s digest is a member of the allowed list, then replace it in a similar\nway it does when the <code>PolicyAuthorize</code> ticket is resolved. The policy is satisfied if one of\nits branches is satisfied.</p>\n<h2 id=\"building-a-secure-policy-based-nvpcr\">Building a secure, policy-based NvPCR</h2>\n<p>With the previously introduced primitives, we can now take a look at how systemd\nconstructs a secure NvPCR. The NV index is protected by a write policy with two\nbranches that are connected with a <code>PolicyOR</code>: A <code>PolicyAuthorize</code> branch with a\npublic key and the <code>policyRef</code> <code>initrd</code>, and a <code>PolicyNvWritten(true)</code> branch.</p>\n<p>Let’s take a look at the <code>PolicyNvWritten(true)</code> branch first. This assertion allows\nchecking for the <code>written</code> attribute as part of a policy&lt;sup&gt;14&lt;/sup&gt;. So <code>PolicyNvWritten(true)</code> can be\nsatisfied without further authorization if the NvPCR has already been extended before. This is\nthe branch that is used after the initial setup, during runtime. At that point, extending\nthe NvPCR doesn’t require additional authentication and can be done by any component with\naccess to the device.</p>\n<p>The other branch, <code>PolicyAuthorize</code>, can be satisfied with a signed policy. This\nbranch must be used for the initial extend of the NvPCR. The concrete policy\nused by systemd is a PCR policy for PCR 11, matching the expected state of that PCR\nwhen running in the initrd during early boot. systemd tracks the boot phase in PCR 11:\nWhen the initrd is started, <code>systemd-pcrphase-initrd.service</code> measures the event\n<code>enter-initrd</code>. We construct the PCR policy against the expected state of PCR 11 after\nthis event. If the system hasn’t been tampered with up to that point and the signature\nis valid, the signed PCR policy is satisfied and the NvPCR can be initially extended.\nWhen progressing to the next boot phase, the same service measures the phase event\n<code>leave-initrd</code>. This locks the <code>PolicyAuthorize</code> branch for the rest\nof the system lifetime. The NvPCR can then only be extended via the <code>PolicyNvWritten</code>\nbranch.</p>\n<p>You might wonder why the write policy takes the indirection via <code>PolicyAuthorize</code>\ninstead of embedding the PCR policy directly. On a real system, PCR 11 doesn’t only\ncontain the phase events: the UKI stub measures the kernel and initrd into it first,\nso the initrd state of PCR 11 changes with every update. Embedded directly, every\nupdate would change the write policy and with it the name of the index, and the NvPCR\nwould have to be recreated on every update. With <code>PolicyAuthorize</code>, the write policy\nand the name stay stable, and only the signature shipped with the UKI changes.</p>\n<p>Let’s construct this final NvPCR version, similar to how systemd does it.\nMeasure the event that marks the start of the initrd boot phase, as done by\n<code>systemd-pcrphase-initrd.service</code>:</p>\n<pre><code>echo -n &quot;enter-initrd&quot; | tpm2_pcrevent 11\ntpm2_pcrread sha256:11</code></pre>\n<pre><code>  sha256:\n    11: 0xD15B0E8E244E65C40F024E95773F2347CE4EF3FFE6B597C9A14B50BBAB6DF319</code></pre>\n<p>Let’s author the write policy.\nThe key from the previous section is reused. It takes the role of the UKI’s PCR signing\nkey, whose public half is shipped in the <code>.pcrpkey</code> section of the UKI. First write the\n<code>policyRef</code> with the value <code>initrd</code>&lt;sup&gt;15&lt;/sup&gt;. Then use a trial session to create the key-based\nbranch of the policy that must be used for the first write from within the initrd:</p>\n<pre><code>printf &#39;initrd&#39; &gt; initrd.ref\ntpm2_startauthsession --session=trial.ctx\ntpm2_policyauthorize --session=trial.ctx \\\n  --name=signkey.name \\\n  --qualification=initrd.ref \\\n  --policy=init.branch\ntpm2_flushcontext trial.ctx</code></pre>\n<p>Next, create the second branch of the policy, the <code>PolicyNvWritten(true)</code>:</p>\n<pre><code>tpm2_startauthsession --session=trial.ctx\ntpm2_policynvwritten --session=trial.ctx --policy=written.branch s\ntpm2_flushcontext trial.ctx</code></pre>\n<p>And finally combine the two policies with a <code>PolicyOR</code>:</p>\n<pre><code>tpm2_startauthsession --session=trial.ctx\ntpm2_policyor --session=trial.ctx \\\n  --policy-list=sha256:init.branch,written.branch \\\n  --policy=write.policy\ntpm2_flushcontext trial.ctx</code></pre>\n<p>We use that policy to define the NvPCR, nearly identical to how we did before:</p>\n<pre><code>tpm2_nvdefine 0x01D10200 \\\n    --hierarchy=o \\\n    --hash-algorithm=sha256 \\\n    --attributes=&quot;nt=extend|policywrite|ownerread|authread|clear_stclear&quot; \\\n    --policy=write.policy\ntpm2_nvreadpublic 0x01D10200</code></pre>\n<pre><code>0x1d10200:\n    name: 000bf2e615c91fc1738ee23d6906f3cc5da932ed3d3dd19f99de0d0e0839712d8ecf\n    ...\n    attributes:\n      friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear\n      value: 0x8060048\n    size: 32\n    authorization policy: A765636C5A04447BD790486F98DAF82CA694868DF19C1AF80632A52261E374EF</code></pre>\n<p>As the write policy depends on your generated key, your authorization policy and the index\nname will again differ from mine. The only thing new here is the <code>clear_stclear</code> attribute:\nIt tells the TPM to clear the NV index on reboot&lt;sup&gt;16&lt;/sup&gt;. Similar to PCRs, the NvPCR should reset\non reboot, not persist measurements of a previous boot to the next. The <code>written</code> attribute\nis also cleared on reset.</p>\n<p><code>0x01D10200</code> is the real handle from the NV index range systemd uses. Which NvPCRs exist on a\nsystem is defined by small JSON files in <code>/usr/lib/nvpcr/*.nvpcr</code>, which since v262 must\nbe shipped as part of the UKI. systemd currently ships four definitions: <code>hardware</code> for the\nproduct UUID, <code>cryptsetup</code> for the LUKS unlock mechanism used, <code>verity</code> for the root hashes\nof activated verity volumes, and <code>login</code> for user logins&lt;sup&gt;17&lt;/sup&gt;. All four record events that are\nunpredictable or unbounded in number, exactly the kind we don’t want in the real PCRs.</p>\n<p>Then we craft the concrete PCR policy for the expected initrd state of PCR 11\nand sign it together with the <code>policyRef</code>:</p>\n<pre><code>tpm2_startauthsession --session=trial.ctx\ntpm2_policypcr --session=trial.ctx --pcr-list=sha256:11 --policy=initrd.policy\ntpm2_flushcontext trial.ctx\ncat initrd.policy initrd.ref &gt; tbs.bin\nopenssl dgst -sha256 -sign sign.key.pem -out initrd.sig tbs.bin</code></pre>\n<p>In systemd, this is done at image build time with ukify. The tool gained a new flag <code>--sign-initrd-pcrs</code>\nthat precomputes the expected PCR 11 value for the <code>enter-initrd</code> phase and embeds the signed policy in the\nUKI into a <code>.pcrsig</code> section.</p>\n<p>On each boot, systemd’s <code>systemd-tpm2-setup-early.service</code> uses the signature and the\nauthorize policy path to initialize the NvPCR in the initrd. First, we let the TPM\nverify the signature:</p>\n<pre><code>tpm2_verifysignature --key-context=signkey.ctx \\\n  --hash-algorithm=sha256 --message=tbs.bin --scheme=rsassa \\\n  --signature=initrd.sig --ticket=initrd.tkt</code></pre>\n<p>Then we start a policy session. We satisfy the PCR policy with the current state of PCR 11,\nthen call <code>tpm2_policyauthorize</code> to present the signature over the <code>initrd.policy</code>.\nFinally, we call <code>tpm2_policyor</code> to present both branches and match the expected auth digest of\nour NvPCR:</p>\n<pre><code>tpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policypcr --session=s.ctx --pcr-list=sha256:11\ntpm2_policyauthorize --session=s.ctx --input=initrd.policy \\\n  --qualification=initrd.ref --name=signkey.name --ticket=initrd.tkt\ntpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch</code></pre>\n<p>With this session state, the policy is satisfied and we extend the NvPCR. systemd initializes\nNvPCRs with all-zeros. The written value doesn’t matter, the point is that the index gains the\n<code>written</code> attribute, and that this first extend happened under the signed policy.</p>\n<pre><code>head -c 32 /dev/zero &gt; zero.bin\ntpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=zero.bin\ntpm2_flushcontext s.ctx\ntpm2_flushcontext --transient-object\ntpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64</code></pre>\n<pre><code>f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b</code></pre>\n<p>Because the extended value is all-zeros, every freshly initialized SHA-256 NvPCR starts\nout at this exact value, <code>SHA256(zeros32 || zeros32)</code>, on every machine. With the first\nwrite done, the index gained the <code>written</code> attribute and thereby a new name:</p>\n<pre><code>tpm2_nvreadpublic 0x01D10200</code></pre>\n<pre><code>0x1d10200:\n    name: 000bf08c214c037f85d3520549dba23471a88af8aab1913bf269edfbb44b6d2de9f5\n    ...\n    attributes:\n      friendly: policywrite|nt=0x1|ownerread|authread|clear_stclear|written\n      value: 0x28060048</code></pre>\n<p>Keep this name in mind, we will get back to it in the next section.</p>\n<p>At the end of the initrd boot phase, systemd will measure the event marking the end of that phase:</p>\n<pre><code>echo -n &quot;leave-initrd&quot; | tpm2_pcrevent 11</code></pre>\n<p>With this, the signed policy cannot be satisfied anymore until the next reboot!</p>\n<p>Later at runtime, the write is authorized solely on the <code>written</code> property the NvPCR gained during\nits initialization in initrd. This is what <code>systemd-pcrextend</code> does when it records an event, and\nit requires no key and no secret, any component with access to the TPM can append. That is\ndeliberate: extends of real PCRs are unauthenticated, too, because adding history is harmless,\nonly rewriting it must be impossible.</p>\n<pre><code>tpm2_startauthsession --session=s.ctx --policy-session\ntpm2_policynvwritten --session=s.ctx s\ntpm2_policyor --session=s.ctx --policy-list=sha256:init.branch,written.branch\ntpm2_nvextend 0x01D10200 --hierarchy=0x01D10200 --auth=session:s.ctx --input=m1.bin\ntpm2_flushcontext s.ctx\ntpm2_nvread 0x01D10200 --size=32 | xxd -p -c 64</code></pre>\n<pre><code>5bbe1770fd46c5edfde5ef444b2d681d87925fcd71b1fe98c4f65749a6f3afb6</code></pre>\n<p>Notice that we still need to present both branches to the session, including the <code>init.branch</code>\ndigest. For that reason systemd persists it to <code>/run/systemd/nvpcr/&lt;name&gt;.auth</code> after\ninitialization.</p>\n<p>Like for regular PCRs, systemd records each NvPCR measurement in its userspace\nmeasurement log (<code>/run/log/systemd/tpm2-measure.log</code>), so that a verifier can later\nreplay the events and interpret the NvPCR value.</p>\n<p>This concludes the hands-on part. When you are done experimenting, shut the software TPM down:</p>\n<pre><code>kill &quot;$(cat swtpm.pid)&quot;</code></pre>\n<h2 id=\"security-considerations-of-the-nvpcr-design\">Security considerations of the NvPCR design</h2>\n<p>Let’s take a look at the security this construction is providing us with. The attacker\nwe are looking at gains access during runtime of the system, after the <code>leave-initrd</code>\nevent is measured, and wants to rewrite the NvPCR history unnoticed, like Alice hiding\nher login in that naive index attack from the beginning.\nThey have root privilege and can access the TPM to read, write, undefine\nand redefine NV indexes, extend (but not reset) PCRs. They can also reboot or boot a\ndifferent OS. Attacks against the TPM hardware itself are out of scope.</p>\n<p>The TPM itself is trusted, and so is the measured boot chain up to and including the initrd, including firmware, bootloader and UKI. The initrd is authorized to initialize NvPCRs by design, which is expressed by the signed policy over the initrd state of PCR 11. But PCR 11 is under OS control, a different kernel could extend the expected values and reach the authorized state, too. It takes the firmware-controlled PCRs, which record the bootloader and UKI that actually ran, to tie the initrd state of PCR 11 to the initrd we trust. Booting anything else is visible to the verifier. We also have to trust the key holder, as whoever controls the PCR signing key can bless arbitrary states, and the verifier, which has to check more than just the NvPCR values, as we will see below.</p>\n<p>The write policy and the public key are no secrets, so the attacker can undefine our\nNvPCR and redefine it, byte-for-byte identical. To replay a history, the attacker then\nhas to perform the first write to the fresh index, and both branches of the write\npolicy refuse: The <code>PolicyNvWritten(true)</code> branch can’t authorize the write. Nothing\nstops the attacker from running the policy commands, and the session digest will even\nmatch the write policy, but the deferred written-check fails against the never-written\nindex, and the TPM rejects the extend. And the <code>PolicyAuthorize</code> branch fails, too: the only\nsignature in existence approves the initrd state of PCR 11, which the machine left\nwhen <code>leave-initrd</code> was measured. The session can’t reach the signed digest anymore,\nso the TPM rejects and the attacker isn’t able to create a forged history.</p>\n<p>There is one more signature the attacker might try: the same key also signs the UKI’s\nother PCR policies, for example the ones used for disk unlock, and some of those are\nsatisfiable in the runtime state of the machine. This is where the\n<code>policyRef</code> comes in: our authorize branch only accepts signatures made for the ref\n<code>initrd</code>, and the other policies are signed with a different ref (or none at all).\nThe signatures are not interchangeable.</p>\n<p>Of course, the attacker doesn’t have to reuse our write policy. They can redefine the\nindex with <code>authwrite</code>, like our naive index from the beginning, and replay whatever\nhistory they like. But remember that the name of an NV index is a hash over all of its\npublic properties: the attributes and the write policy, which in turn commits to the\nvendor’s public key. There is no way to create an index that is writable outside the\ninitrd without ending up with a different name.</p>\n<p>This makes the name the anchor of the whole design, and the last missing piece is\nmaking it verifiable. After initializing an NvPCR, systemd extends the event\n<code>nvpcr-init:&lt;name&gt;:0x&lt;handle&gt;:&lt;tpm-name&gt;</code> into PCR 9, a real, non-resettable PCR.\nIt is the cheapest PCR to sacrifice: the kernel measures into it every initrd it is\nhanded, which on a UKI boot is a concatenation of the UKI’s embedded initrd with cpio\narchives that systemd-stub generates on the fly, for example for credentials, all mangled\ninto a single value, on some setups joined by verbose bootloader records. This makes\nPCR 9 hard to predict, so no unlock policy can bind to it anyway, while the measurement\nthat matters, the UKI initrd, is already cleanly covered by PCR 11.\nOnce all NvPCRs are initialized, <code>systemd-pcrnvdone.service</code> measures a separator\nevent <code>nvpcr-separator</code> into PCR 9, still inside the initrd. The NvPCR values themselves\nare attested with <code>TPM2_NV_Certify</code>, which has the TPM sign the current index contents\ntogether with the index name. The new systemd-report-sign-tpm2 signer emits these\nattestations alongside regular PCR quotes. A verifier doing remote\nattestation must only trust NvPCR values whose attested index name matches an\n<code>nvpcr-init</code> event that appears before the separator in the PCR 9 event log. Any\nrecreated index fails this check: wrong name, or right name but logged after the\nseparator.</p>\n<p>Two capabilities of our attacker are left: rebooting and booting a different OS. A reboot resets the NvPCRs just like the real PCRs, and the next boot re-initializes them in the initrd. The attacker gains nothing: the new boot is a genuine one, its history starts fresh by design, and the verifier can see that a reboot happened. Booting a different OS doesn’t help either, as it produces different measurements in PCR 11 and the other boot chain PCRs. The signed policy can’t be satisfied, and any quote produced from such a boot exposes the manipulated boot chain.</p>\n<h2 id=\"conclusion\">Conclusion</h2>\n<p>An NvPCR in systemd v262 is an NV extend index whose first write is gated by a signed PCR policy that can only be satisfied in the initrd, while all later writes are free. The index name, measured into PCR 9 during early boot, anchors the construction for verifiers: any index recreated with a weaker write policy carries a different name and is detected. We reconstructed such an NvPCR on the command line and discussed why an attacker can’t forge measurements: once the initrd window has closed, no road into the write policy of a fresh index remains, and everything the attacker can still do is destructive and shows up in attestation.</p>\n<p>A practical note to close with: The write policy is based on the UKI’s PCR signing key. When that key rotates, systemd-tpm2-setup detects the name mismatch, checks that the existing index looks like an NvPCR, and recreates it with the new policy. The same path automatically upgrades NvPCRs created by the pre-v262 design, so the roll-out to your systems will happen automatically with the systemd update.</p>\n<p>The initrd-bound signed policy turns out to be useful beyond NvPCRs, too. With\n<code>systemd-cryptenroll --tpm2-public-key-policyref=initrd</code>, you can enroll LUKS keyslots\nthat can only be unlocked from the initrd. And if you want to see all of this on a real\nsystem, boot a v262 image with a UKI built with <code>--sign-initrd-pcrs</code> and take a look at\n<code>systemd-analyze nvpcrs</code> and the PCR 9 event log.</p>\n<h2 id=\"thanks\">Thanks</h2>\n<p>*To my colleague at Amutable Chris Coulson, who authored the new NvPCR design and gave insightful\ncorrections and additions to this blog post. Linux security work in the upstream projects\nwe all rely on is at the heart of our mission at Amutable: building new secure foundations.*</p>\n<h2 id=\"references\">References</h2>\n<ul><li>Add support for nvindex-based additional PCRs for TPM2, aka “NvPCRs” PR on systemd</li><li>tpm2: Improve how NvPCR protection works PR on systemd</li><li>TPM2 PCR Measurements Made by systemd documentation</li><li>TPM 2.0 Library (Section “Latest Version”)</li><li>tpm2-tools man pages</li></ul>\n<ol><li>Dynamic Root of Trust for Measurements is a mechanism to establish a new, verifiable chain of trust at runtime, usually supported by the platform through something like Intel TXT or AMD SVM. ↩︎</li><li>The UAPI.7 spec documents how firmware and OS PCRs are commonly used. ↩︎</li><li>See Section “34.2 NV Indices” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎</li><li>Section “13 Names” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture lists the name equations for all entity types (Table 9), and explicitly describes how the name of an NV index changes when the <code>written</code> attribute is set. ↩︎</li><li>Hierarchies and their authorizations are introduced in Section “10 TPM Control Domains” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture in case you want to dive into it, but not super important for what we are doing here. ↩︎</li><li><p>Notice the <code>nt=0x1</code> in the output is a bug in tpm2-tools.<code>TPM_NT_EXTEND</code> is 0x4 according</p><p>to spec, and the raw value correctly contains that. I’ve opened an upstream PR to fix this. ↩︎</p></li><li><p>Notice that the <code>TPM2_NV_Extend</code> call used by tpm2_nvextend uses the arbitrary bytes provided</p><p>directly in the hash function without pre-hashing:<code>NV := H_nameAlg(NV_old ‖ input)</code> .<code>TPM2_PCR_Extend</code> on the other hand requires a fixed size digest to be passed\n(<code>PCR := H(PCR ‖ digest_in)</code> ), and<code>TPM2_PCR_Event</code> does hashing of the handed event\nitself (<code>PCR := H(PCR ‖ H(event))</code> ). ↩︎</p></li><li>Policies are specified in Section “16.7 Enhanced Authorization” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, which is a surprisingly readable introduction to the topic. Trial sessions are covered in Section “16.7.10 Trial Policy”. ↩︎</li><li><p>Reproducing the hash with some constants from the spec: <code>H(zeros32 || 0000017f || 00000001000b03008000 || H(pcr15_value))</code>On the terminal: ↩︎```</p><p>pcrdigest=$(head -c 32 /dev/zero | sha256sum | cut -d&#39; &#39; -f1)\nprintf &#39;%064d0000017f00000001000b03008000%s&#39; 0 &quot;$pcrdigest&quot; <br />\n  | xxd -r -p | sha256sum</p></li></ol>\n<pre><code>10. `PolicyPCR` can be immediate and deferred at the same time, depending on its\nparameters: the caller may pass the PCR digest they expect, which the TPM then\nchecks against the selected PCRs right away. Called without a digest, like\ntpm2_policypcr does here, the TPM just folds the current PCR values into the`policyDigest` , and whether they were the right ones only shows in the final\ncomparison against`authPolicy` . In both variants, the TPM additionally records\nthe current PCR update counter in the session as a deferred constraint: if PCRs\nare updated after the assertion, the session can no longer authorize anything. ↩︎\n11. The spec discusses the problem of “brittleness” in Section “16.7.11 Modification of Policies” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture, and introduces a similar construction as we are about to build. ↩︎\n12. Tickets are HMACs keyed with an internal TPM proof value, enabling the TPM to re-verify a signature later without loading the asymmetric key again, see “8.4.6.3 Tickets” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture ↩︎\n13. The digest replacement is illustrated in Section “16.7.4 Policy OR” in TCG TPM 2.0 Library Specification, Version 185, Part 1: Architecture. ↩︎\n14. While the policy commands run, the session isn’t tied to any NV index yet, so the written property can’t be checked immediately. `TPM2_PolicyNvWritten` extends\nthe`policyDigest` like any other assertion, but additionally records the\nclaimed written state in the session, as a deferred check. Only when the session is\nused to authorize a command does the TPM compare it against the actual attribute of\nthe accessed index, and reject the command on mismatch. ↩︎\n15. The real systemd implementation doesn’t use the raw string as `policyRef` , but`SHA256(&quot;initrd&quot;)` , as the size of a policy ref is limited. The semantics are the same. ↩︎\n16. More precisely, `clear_stclear` means the index is cleared on any`TPM2_Startup(CLEAR)` ,\nwhich the platform issues on a TPM reset or TPM restart. Besides the reboot, that also\ncovers resume from hibernation (not that Linux systems really hibernate with secure\nboot enabled). Resume from suspend on the other hand corresponds to`TPM2_Startup(STATE)` ,\na TPM resume, which preserves the index. ↩︎\n17. If you inspect these indices on a real system, all but `hardware` will show one more\nattribute:`orderly` . An orderly NV index is backed by TPM RAM and only flushed to\nNVRAM on clean shutdown, sparing the NVRAM from wear on frequently written indices\nlike`login` . TPM RAM is even scarcer than NVRAM, though, so the`hardware` NvPCR,\nwritten only once per boot, isn’t using it. And as`orderly` is an attribute, it is part\nof the index name. ↩︎</code></pre>","headings":[{"level":2,"text":"Why can’t systemd get enough of those PCRs?","id":"why-can-t-systemd-get-enough-of-those-pcrs"},{"level":2,"text":"Setup for follow-along","id":"setup-for-follow-along"},{"level":2,"text":"TPM NV index as PCR replacement","id":"tpm-nv-index-as-pcr-replacement"},{"level":2,"text":"Exploring policies","id":"exploring-policies"},{"level":3,"text":"PolicyPCR","id":"policypcr"},{"level":3,"text":"PolicyAuthorize","id":"policyauthorize"},{"level":3,"text":"PolicyOR","id":"policyor"},{"level":2,"text":"Building a secure, policy-based NvPCR","id":"building-a-secure-policy-based-nvpcr"},{"level":2,"text":"Security considerations of the NvPCR design","id":"security-considerations-of-the-nvpcr-design"},{"level":2,"text":"Conclusion","id":"conclusion"},{"level":2,"text":"Thanks","id":"thanks"},{"level":2,"text":"References","id":"references"}]}}