300 lines
11 KiB
JavaScript
300 lines
11 KiB
JavaScript
|
"use strict";
|
||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||
|
};
|
||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||
|
exports.SuccinctRoles = exports.DelegatedRole = exports.Role = exports.TOP_LEVEL_ROLE_NAMES = void 0;
|
||
|
const crypto_1 = __importDefault(require("crypto"));
|
||
|
const minimatch_1 = require("minimatch");
|
||
|
const util_1 = __importDefault(require("util"));
|
||
|
const error_1 = require("./error");
|
||
|
const utils_1 = require("./utils");
|
||
|
exports.TOP_LEVEL_ROLE_NAMES = [
|
||
|
'root',
|
||
|
'targets',
|
||
|
'snapshot',
|
||
|
'timestamp',
|
||
|
];
|
||
|
/**
|
||
|
* Container that defines which keys are required to sign roles metadata.
|
||
|
*
|
||
|
* Role defines how many keys are required to successfully sign the roles
|
||
|
* metadata, and which keys are accepted.
|
||
|
*/
|
||
|
class Role {
|
||
|
constructor(options) {
|
||
|
const { keyIDs, threshold, unrecognizedFields } = options;
|
||
|
if (hasDuplicates(keyIDs)) {
|
||
|
throw new error_1.ValueError('duplicate key IDs found');
|
||
|
}
|
||
|
if (threshold < 1) {
|
||
|
throw new error_1.ValueError('threshold must be at least 1');
|
||
|
}
|
||
|
this.keyIDs = keyIDs;
|
||
|
this.threshold = threshold;
|
||
|
this.unrecognizedFields = unrecognizedFields || {};
|
||
|
}
|
||
|
equals(other) {
|
||
|
if (!(other instanceof Role)) {
|
||
|
return false;
|
||
|
}
|
||
|
return (this.threshold === other.threshold &&
|
||
|
util_1.default.isDeepStrictEqual(this.keyIDs, other.keyIDs) &&
|
||
|
util_1.default.isDeepStrictEqual(this.unrecognizedFields, other.unrecognizedFields));
|
||
|
}
|
||
|
toJSON() {
|
||
|
return {
|
||
|
keyids: this.keyIDs,
|
||
|
threshold: this.threshold,
|
||
|
...this.unrecognizedFields,
|
||
|
};
|
||
|
}
|
||
|
static fromJSON(data) {
|
||
|
const { keyids, threshold, ...rest } = data;
|
||
|
if (!utils_1.guard.isStringArray(keyids)) {
|
||
|
throw new TypeError('keyids must be an array');
|
||
|
}
|
||
|
if (typeof threshold !== 'number') {
|
||
|
throw new TypeError('threshold must be a number');
|
||
|
}
|
||
|
return new Role({
|
||
|
keyIDs: keyids,
|
||
|
threshold,
|
||
|
unrecognizedFields: rest,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
exports.Role = Role;
|
||
|
function hasDuplicates(array) {
|
||
|
return new Set(array).size !== array.length;
|
||
|
}
|
||
|
/**
|
||
|
* A container with information about a delegated role.
|
||
|
*
|
||
|
* A delegation can happen in two ways:
|
||
|
* - ``paths`` is set: delegates targets matching any path pattern in ``paths``
|
||
|
* - ``pathHashPrefixes`` is set: delegates targets whose target path hash
|
||
|
* starts with any of the prefixes in ``pathHashPrefixes``
|
||
|
*
|
||
|
* ``paths`` and ``pathHashPrefixes`` are mutually exclusive: both cannot be
|
||
|
* set, at least one of them must be set.
|
||
|
*/
|
||
|
class DelegatedRole extends Role {
|
||
|
constructor(opts) {
|
||
|
super(opts);
|
||
|
const { name, terminating, paths, pathHashPrefixes } = opts;
|
||
|
this.name = name;
|
||
|
this.terminating = terminating;
|
||
|
if (opts.paths && opts.pathHashPrefixes) {
|
||
|
throw new error_1.ValueError('paths and pathHashPrefixes are mutually exclusive');
|
||
|
}
|
||
|
this.paths = paths;
|
||
|
this.pathHashPrefixes = pathHashPrefixes;
|
||
|
}
|
||
|
equals(other) {
|
||
|
if (!(other instanceof DelegatedRole)) {
|
||
|
return false;
|
||
|
}
|
||
|
return (super.equals(other) &&
|
||
|
this.name === other.name &&
|
||
|
this.terminating === other.terminating &&
|
||
|
util_1.default.isDeepStrictEqual(this.paths, other.paths) &&
|
||
|
util_1.default.isDeepStrictEqual(this.pathHashPrefixes, other.pathHashPrefixes));
|
||
|
}
|
||
|
isDelegatedPath(targetFilepath) {
|
||
|
if (this.paths) {
|
||
|
return this.paths.some((pathPattern) => isTargetInPathPattern(targetFilepath, pathPattern));
|
||
|
}
|
||
|
if (this.pathHashPrefixes) {
|
||
|
const hasher = crypto_1.default.createHash('sha256');
|
||
|
const pathHash = hasher.update(targetFilepath).digest('hex');
|
||
|
return this.pathHashPrefixes.some((pathHashPrefix) => pathHash.startsWith(pathHashPrefix));
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
toJSON() {
|
||
|
const json = {
|
||
|
...super.toJSON(),
|
||
|
name: this.name,
|
||
|
terminating: this.terminating,
|
||
|
};
|
||
|
if (this.paths) {
|
||
|
json.paths = this.paths;
|
||
|
}
|
||
|
if (this.pathHashPrefixes) {
|
||
|
json.path_hash_prefixes = this.pathHashPrefixes;
|
||
|
}
|
||
|
return json;
|
||
|
}
|
||
|
static fromJSON(data) {
|
||
|
const { keyids, threshold, name, terminating, paths, path_hash_prefixes, ...rest } = data;
|
||
|
if (!utils_1.guard.isStringArray(keyids)) {
|
||
|
throw new TypeError('keyids must be an array of strings');
|
||
|
}
|
||
|
if (typeof threshold !== 'number') {
|
||
|
throw new TypeError('threshold must be a number');
|
||
|
}
|
||
|
if (typeof name !== 'string') {
|
||
|
throw new TypeError('name must be a string');
|
||
|
}
|
||
|
if (typeof terminating !== 'boolean') {
|
||
|
throw new TypeError('terminating must be a boolean');
|
||
|
}
|
||
|
if (utils_1.guard.isDefined(paths) && !utils_1.guard.isStringArray(paths)) {
|
||
|
throw new TypeError('paths must be an array of strings');
|
||
|
}
|
||
|
if (utils_1.guard.isDefined(path_hash_prefixes) &&
|
||
|
!utils_1.guard.isStringArray(path_hash_prefixes)) {
|
||
|
throw new TypeError('path_hash_prefixes must be an array of strings');
|
||
|
}
|
||
|
return new DelegatedRole({
|
||
|
keyIDs: keyids,
|
||
|
threshold,
|
||
|
name,
|
||
|
terminating,
|
||
|
paths,
|
||
|
pathHashPrefixes: path_hash_prefixes,
|
||
|
unrecognizedFields: rest,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
exports.DelegatedRole = DelegatedRole;
|
||
|
// JS version of Ruby's Array#zip
|
||
|
const zip = (a, b) => a.map((k, i) => [k, b[i]]);
|
||
|
function isTargetInPathPattern(target, pattern) {
|
||
|
const targetParts = target.split('/');
|
||
|
const patternParts = pattern.split('/');
|
||
|
if (patternParts.length != targetParts.length) {
|
||
|
return false;
|
||
|
}
|
||
|
return zip(targetParts, patternParts).every(([targetPart, patternPart]) => (0, minimatch_1.minimatch)(targetPart, patternPart));
|
||
|
}
|
||
|
/**
|
||
|
* Succinctly defines a hash bin delegation graph.
|
||
|
*
|
||
|
* A ``SuccinctRoles`` object describes a delegation graph that covers all
|
||
|
* targets, distributing them uniformly over the delegated roles (i.e. bins)
|
||
|
* in the graph.
|
||
|
*
|
||
|
* The total number of bins is 2 to the power of the passed ``bit_length``.
|
||
|
*
|
||
|
* Bin names are the concatenation of the passed ``name_prefix`` and a
|
||
|
* zero-padded hex representation of the bin index separated by a hyphen.
|
||
|
*
|
||
|
* The passed ``keyids`` and ``threshold`` is used for each bin, and each bin
|
||
|
* is 'terminating'.
|
||
|
*
|
||
|
* For details: https://github.com/theupdateframework/taps/blob/master/tap15.md
|
||
|
*/
|
||
|
class SuccinctRoles extends Role {
|
||
|
constructor(opts) {
|
||
|
super(opts);
|
||
|
const { bitLength, namePrefix } = opts;
|
||
|
if (bitLength <= 0 || bitLength > 32) {
|
||
|
throw new error_1.ValueError('bitLength must be between 1 and 32');
|
||
|
}
|
||
|
this.bitLength = bitLength;
|
||
|
this.namePrefix = namePrefix;
|
||
|
// Calculate the suffix_len value based on the total number of bins in
|
||
|
// hex. If bit_length = 10 then number_of_bins = 1024 or bin names will
|
||
|
// have a suffix between "000" and "3ff" in hex and suffix_len will be 3
|
||
|
// meaning the third bin will have a suffix of "003".
|
||
|
this.numberOfBins = Math.pow(2, bitLength);
|
||
|
// suffix_len is calculated based on "number_of_bins - 1" as the name
|
||
|
// of the last bin contains the number "number_of_bins -1" as a suffix.
|
||
|
this.suffixLen = (this.numberOfBins - 1).toString(16).length;
|
||
|
}
|
||
|
equals(other) {
|
||
|
if (!(other instanceof SuccinctRoles)) {
|
||
|
return false;
|
||
|
}
|
||
|
return (super.equals(other) &&
|
||
|
this.bitLength === other.bitLength &&
|
||
|
this.namePrefix === other.namePrefix);
|
||
|
}
|
||
|
/***
|
||
|
* Calculates the name of the delegated role responsible for 'target_filepath'.
|
||
|
*
|
||
|
* The target at path ''target_filepath' is assigned to a bin by casting
|
||
|
* the left-most 'bit_length' of bits of the file path hash digest to
|
||
|
* int, using it as bin index between 0 and '2**bit_length - 1'.
|
||
|
*
|
||
|
* Args:
|
||
|
* target_filepath: URL path to a target file, relative to a base
|
||
|
* targets URL.
|
||
|
*/
|
||
|
getRoleForTarget(targetFilepath) {
|
||
|
const hasher = crypto_1.default.createHash('sha256');
|
||
|
const hasherBuffer = hasher.update(targetFilepath).digest();
|
||
|
// can't ever need more than 4 bytes (32 bits).
|
||
|
const hashBytes = hasherBuffer.subarray(0, 4);
|
||
|
// Right shift hash bytes, so that we only have the leftmost
|
||
|
// bit_length bits that we care about.
|
||
|
const shiftValue = 32 - this.bitLength;
|
||
|
const binNumber = hashBytes.readUInt32BE() >>> shiftValue;
|
||
|
// Add zero padding if necessary and cast to hex the suffix.
|
||
|
const suffix = binNumber.toString(16).padStart(this.suffixLen, '0');
|
||
|
return `${this.namePrefix}-${suffix}`;
|
||
|
}
|
||
|
*getRoles() {
|
||
|
for (let i = 0; i < this.numberOfBins; i++) {
|
||
|
const suffix = i.toString(16).padStart(this.suffixLen, '0');
|
||
|
yield `${this.namePrefix}-${suffix}`;
|
||
|
}
|
||
|
}
|
||
|
/***
|
||
|
* Determines whether the given ``role_name`` is in one of
|
||
|
* the delegated roles that ``SuccinctRoles`` represents.
|
||
|
*
|
||
|
* Args:
|
||
|
* role_name: The name of the role to check against.
|
||
|
*/
|
||
|
isDelegatedRole(roleName) {
|
||
|
const desiredPrefix = this.namePrefix + '-';
|
||
|
if (!roleName.startsWith(desiredPrefix)) {
|
||
|
return false;
|
||
|
}
|
||
|
const suffix = roleName.slice(desiredPrefix.length, roleName.length);
|
||
|
if (suffix.length != this.suffixLen) {
|
||
|
return false;
|
||
|
}
|
||
|
// make sure the suffix is a hex string
|
||
|
if (!suffix.match(/^[0-9a-fA-F]+$/)) {
|
||
|
return false;
|
||
|
}
|
||
|
const num = parseInt(suffix, 16);
|
||
|
return 0 <= num && num < this.numberOfBins;
|
||
|
}
|
||
|
toJSON() {
|
||
|
const json = {
|
||
|
...super.toJSON(),
|
||
|
bit_length: this.bitLength,
|
||
|
name_prefix: this.namePrefix,
|
||
|
};
|
||
|
return json;
|
||
|
}
|
||
|
static fromJSON(data) {
|
||
|
const { keyids, threshold, bit_length, name_prefix, ...rest } = data;
|
||
|
if (!utils_1.guard.isStringArray(keyids)) {
|
||
|
throw new TypeError('keyids must be an array of strings');
|
||
|
}
|
||
|
if (typeof threshold !== 'number') {
|
||
|
throw new TypeError('threshold must be a number');
|
||
|
}
|
||
|
if (typeof bit_length !== 'number') {
|
||
|
throw new TypeError('bit_length must be a number');
|
||
|
}
|
||
|
if (typeof name_prefix !== 'string') {
|
||
|
throw new TypeError('name_prefix must be a string');
|
||
|
}
|
||
|
return new SuccinctRoles({
|
||
|
keyIDs: keyids,
|
||
|
threshold,
|
||
|
bitLength: bit_length,
|
||
|
namePrefix: name_prefix,
|
||
|
unrecognizedFields: rest,
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
exports.SuccinctRoles = SuccinctRoles;
|