The Update Framework: metadata verification and root rotation
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
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
- spec TUF
pki.tuf.keyId
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
- spec TUF
pki.tuf.parseMetadata
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 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.
Example
var m = pki.tuf.parseMetadata(Buffer.from(JSON.stringify({ signatures: [],
signed: { _type: "root", version: 1, expires: "2030-01-01T00:00:00Z" } })));
m.type; // -> "root"
References
- spec TUF
pki.tuf.checkExpiry
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.
Example
var meta = pki.tuf.parseMetadata(Buffer.from(JSON.stringify({ signatures: [],
signed: { _type: "root", version: 1, expires: "2030-01-01T00:00:00Z" } })));
pki.tuf.checkExpiry(meta, new Date("2027-01-01T00:00:00Z")); // -> true
References
- spec TUF
pki.tuf.verifySignatures
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, while a key or signature the build cannot read throws.
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. - 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.
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", version: 1, expires: "2030-01-01T00:00:00Z",
keys: keys, roles: { root: { 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
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;
var signed = { _type: "root", version: 1, expires: "2030-01-01T00:00:00Z",
keys: keys, roles: { root: { 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)