The Update Framework: metadata verification and root rotation

The Update Framework's metadata layer: the signed documents a repository publishes to say which keys may sign what, and the rules a client applies before believing any of it. This is the trust root a sigstore verifier would otherwise have pinned by hand.

TUF signs the canonical JSON form of a document's signed object, so canonicalJson is the wire format rather than a convenience: it sorts object keys by code point, emits no whitespace, and escapes only backslash and quote, leaving a literal control character as itself. JSON.stringify would write \n where this writes a newline byte, so the two produce different signatures over the same document. A float is refused rather than rounded, because the reference encoder cannot write one.

keyId derives a key's identifier as the hex SHA-256 of its own canonical form, and verifySignatures recomputes it for every key a role names, so a document cannot list one key under another's identifier. A threshold counts one verified signature per distinct identifier, which is what the specification requires of a client meeting the same key twice.

updateRoot walks the root chain: each new root must be signed by a threshold of the keys the trusted root names AND a threshold of its own, and its version must be exactly one more than the trusted one. That pair of rules is what makes a key rotation safe and a skipped intermediate impossible. Verifying only, and transport-free: nothing here fetches.

pki.tuf.canonicalJson

since 0.8.41 stable
pki.tuf.canonicalJson(value) -> Buffer

The canonical JSON encoding of a value, as UTF-8 bytes. This is the preimage TUF signatures cover, so it is a wire format and not a formatting choice: object keys are sorted by code point, no whitespace is emitted, and inside a string only backslash and quote are escaped. A literal control character is emitted as itself, which is where JSON.stringify differs: it would write \n and the signature would be over other bytes.

Only strings, exact integers, booleans, null, arrays and plain objects have an encoding. A float, a number outside the exactly-representable integers, undefined and a function are refused with tuf/bad-input rather than approximated, and a document nesting past C.LIMITS.JSON_MAX_DEPTH is tuf/too-deep.

Example

pki.tuf.canonicalJson({ b: 2, a: 1 }).toString("utf8");   // '{"a":1,"b":2}'

References

pki.tuf.keyId

since 0.8.41 stable
pki.tuf.keyId(key) -> string

A key's identifier: the lowercase hex SHA-256 of the key's own canonical JSON form. The specification says a client must calculate each identifier to check it is correct for its key, which is what stops a document listing one key under another's identifier, so this is a verification input rather than a label.

A key object carries keytype, scheme and keyval; a value missing any of them is tuf/bad-key.

Example

pki.tuf.keyId({ keytype: "ed25519", scheme: "ed25519",
  keyval: { public: "00".repeat(32) } }).length;   // -> 64

References

pki.tuf.parseMetadata

since 0.8.41 stable
pki.tuf.parseMetadata(input) -> { type, specVersion, version, expires, signed, signatures, signedBytes }

Read a TUF metadata document: the signatures wrapper and the signed body, with signedBytes the canonical form the signatures cover. A duplicate JSON member is refused before any field is read, because the canonical form would carry only one of them and the signature would then cover a document different from the one delivered.

_type, a spec_version, a version that is a positive integer, and an expires that parses as a date-time are required; a document missing any is tuf/bad-metadata.

spec_version must name a specification version this build implements, which is 1.x, and the major component is the match: a later 1.x minor is read, another major version is tuf/unsupported-spec- version. The rule is here rather than in each verb because a key identifier and an expiry are both read under the rules of this version.

Example

var m = pki.tuf.parseMetadata(Buffer.from(JSON.stringify({ signatures: [],
  signed: { _type: "root", spec_version: "1.0.31", version: 1, expires: "2030-01-01T00:00:00Z" } })));
m.type;   // -> "root"

References

pki.tuf.checkExpiry

since 0.8.41 stable
pki.tuf.checkExpiry(metadata, now) -> true

Assert that metadata has not expired at now, which is the freeze-attack check: a repository that stops publishing leaves a client holding metadata that stays valid forever unless its expiry is read. Returns true, or throws tuf/expired naming both instants. The instant is a caller value, never the system clock read inside the check, so a verdict is reproducible.

The expiry read is metadata.signed.expires, the field the signature covers, and not the expires beside it: the signature check and this one have to be about one document, and an object carrying a lapsed expiry in the body and a future one in the copy otherwise verified and passed here at the same instant. Every copy the object states beside the body must agree with it, type, version, expires and specVersion alike, and so must a signedBytes it carries; any of them contradicting the body is tuf/bad-input. An object carrying no signedBytes is still read, a caller being free to ask only about an expiry.

The body's spec_version must name a specification version this build implements, the expiry format being one this version states. A document parseMetadata returned always carries it; an object assembled by hand without it is tuf/bad-input, and one naming another major version is tuf/unsupported-spec-version.

Example

var meta = pki.tuf.parseMetadata(Buffer.from(JSON.stringify({ signatures: [],
  signed: { _type: "root", spec_version: "1.0.31", version: 1, expires: "2030-01-01T00:00:00Z" } })));
pki.tuf.checkExpiry(meta, new Date("2027-01-01T00:00:00Z"));   // -> true

References

pki.tuf.verifySignatures

since 0.8.41 stable
pki.tuf.verifySignatures(opts) -> Promise<{ verified, keyIds, threshold }>

Verify a role's threshold over parsed metadata. Resolves verified and the distinct keyIds that counted: whether a threshold was met is a verdict about the metadata, so a shortfall resolves false rather than throwing. A KEY the build cannot read throws, the role naming a signer it cannot identify; a SIGNATURE it cannot read counts for nothing, that being one signer short rather than a malformed document.

Three rules the specification states, each applied here:

- A signature counts only if its keyid is one the role names. One from any other key contributes nothing, whatever it verifies over. - One verified signature per DISTINCT key identifier. A key listed twice, or signing twice, counts once, so a threshold cannot be met by repetition. Where an identifier carries more than one signature the FIRST is the one asked: a second cannot raise a count already capped at one, and asking it would let a document choose how many times the signed body is hashed. - Every key's identifier is recomputed from the key itself and must equal the identifier it is listed under. A key filed under another's identifier is tuf/bad-key, not a key that fails to verify: the document is malformed rather than unsigned.

The document verified is derived from one read of metadata.signed, and metadata.signedBytes must equal that canonical form, so the bytes the signatures are checked over are the ones the object states. A signedBytes holding anything else is tuf/bad-input, which is what a parsed object whose Buffer was overwritten after the parse reports.

That body's spec_version must name a specification version this build implements, a key identifier being a hash of the canonical form this version defines. Another major version is tuf/unsupported-spec-version. No spec_version at all, or a metadata.specVersion that contradicts the body's, is tuf/bad-input.

Options

metadata:  object,  // from pki.tuf.parseMetadata
keys:      object,  // the KEYID -> key map the root states
role:      object,  // { keyids, threshold } for the role being checked

Example

async function example() {
  var pair = await pki.key.generate("Ed25519");
  var raw = pki.asn1.decode(await pki.key.export(pair.publicKey)).children[1].content.subarray(1);
  var key = { keytype: "ed25519", scheme: "ed25519", keyval: { public: raw.toString("hex") } };
  var id = pki.tuf.keyId(key), keys = {}; keys[id] = key;
  var signed = { _type: "root", spec_version: "1.0.31", version: 1, expires: "2030-01-01T00:00:00Z",
    keys: keys, roles: { root: { keyids: [id], threshold: 1 },
      targets: { keyids: [id], threshold: 1 }, snapshot: { keyids: [id], threshold: 1 },
      timestamp: { keyids: [id], threshold: 1 } } };
  var sig = Buffer.from(await pki.webcrypto.subtle.sign({ name: "Ed25519" }, pair.privateKey,
    pki.tuf.canonicalJson(signed)));
  var rootBytes = Buffer.from(JSON.stringify({
    signatures: [{ keyid: id, sig: sig.toString("hex") }], signed: signed }));
  var v = await pki.tuf.verifySignatures({ metadata: pki.tuf.parseMetadata(rootBytes),
    keys: signed.keys, role: signed.roles.root });
  v.verified;   // -> true
}
example();

References

  • spec TUF
  • defends signature-bypass (CWE-347)

pki.tuf.updateRoot

since 0.8.41 stable
pki.tuf.updateRoot(opts) -> Promise<{ root, version, updated, walked }>

Walk the root chain from a trusted root to the latest candidate, applying the two rules that make a key rotation safe. Each new root must be signed by a threshold of the keys the PREVIOUS root names and a threshold of the keys it names ITSELF: the first says the rotation was authorized by whoever held the old keys, the second that the new keys can actually sign. A root meeting only one is tuf/root-unsigned.

Its version must be exactly one more than the trusted one. A candidate that skips a version is tuf/bad-root-version, so no intermediate root can be passed over, and one at or below the trusted version is refused by the same rule, which is the rollback check.

The trusted root is itself verified against its own role before any candidate is read, because a chain anchored in something unchecked proves nothing.

Expiry is judged once, at the end, on the root the chain finishes on, at the now the caller supplies. That is where the specification puts the freeze-attack check, and it is what lets a client whose pinned root has lapsed catch up through the successors it is handed instead of needing its trust anchor replaced by some other means. An intermediate root's own expiry is not checked, and with no candidates the root the chain finishes on is the trusted root, so a lapsed root with nothing to move to is tuf/expired.

candidates are the intermediate roots in any order; walked reports the versions adopted, and updated whether any candidate was.

Options

trustedRoot: BufferSource,  // the root the caller already trusts
candidates:  Array,         // the later root documents, each a BufferSource
now:         Date,          // the instant expiry is judged at

Example

async function example() {
  var pair = await pki.key.generate("Ed25519");
  var raw = pki.asn1.decode(await pki.key.export(pair.publicKey)).children[1].content.subarray(1);
  var key = { keytype: "ed25519", scheme: "ed25519", keyval: { public: raw.toString("hex") } };
  var id = pki.tuf.keyId(key), keys = {}; keys[id] = key;
  // `spec_version` is mandatory on a root this verb adopts, and its major version has to be one this
  // build implements: another major version states rules these checks do not have, and naming none
  // states nothing to match against.
  var signed = { _type: "root", spec_version: "1.0.31", version: 1, expires: "2030-01-01T00:00:00Z",
    keys: keys, roles: { root: { keyids: [id], threshold: 1 },
      targets: { keyids: [id], threshold: 1 }, snapshot: { keyids: [id], threshold: 1 },
      timestamp: { keyids: [id], threshold: 1 } } };
  var sig = Buffer.from(await pki.webcrypto.subtle.sign({ name: "Ed25519" }, pair.privateKey,
    pki.tuf.canonicalJson(signed)));
  var rootBytes = Buffer.from(JSON.stringify({
    signatures: [{ keyid: id, sig: sig.toString("hex") }], signed: signed }));
  var r = await pki.tuf.updateRoot({ trustedRoot: rootBytes, candidates: [],
    now: new Date("2027-01-01T00:00:00Z") });
  r.updated;   // -> false
}
example();

References

  • spec TUF
  • defends signature-bypass (CWE-347)