├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bin ├── shepherd └── shepherd.php ├── composer.json ├── data ├── .gitkeep ├── certs │ └── .gitkeep └── config.json ├── doc ├── README.md ├── SECURITY.md ├── cli │ └── README.md └── config.md ├── phpunit.xml.dist ├── psalm.xml ├── sql ├── mysql │ └── tables.sql ├── pgsql │ └── tables.sql └── sqlite │ └── tables.sql ├── src ├── CommandLine │ ├── Command │ │ ├── AddRemote.php │ │ ├── Fact.php │ │ ├── GetUpdate.php │ │ ├── Help.php │ │ ├── ListProducts.php │ │ ├── ListUpdates.php │ │ ├── ListVendors.php │ │ ├── Review.php │ │ ├── Transcribe.php │ │ └── VendorKeys.php │ ├── CommandInterface.php │ ├── ConfigurableTrait.php │ ├── DatabaseTrait.php │ ├── PromptTrait.php │ └── README.md ├── Config.php ├── Data │ ├── Cacheable.php │ ├── Local.php │ ├── ObjectCache.php │ └── Remote.php ├── Exception │ ├── ChronicleException.php │ ├── EmptyValueException.php │ ├── EncodingError.php │ ├── FilesystemException.php │ └── InvalidOperationException.php ├── Herd.php ├── History.php └── Model │ ├── HistoryRecord.php │ ├── Product.php │ ├── Release.php │ └── Vendor.php └── tests ├── HerdTest.php ├── HistoryTest.php ├── config ├── empty.json ├── only-primary-remote.json ├── public-test.json └── valid.json └── create-sqlite-database.php /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /composer.lock 3 | /vendor 4 | /tests/*.sql 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | 5 | - 7.0 6 | - 7.1 7 | - 7.2 8 | - 7.3 9 | 10 | before_install: 11 | - composer self-update 12 | - composer update 13 | - php tests/create-sqlite-database.php tests/empty.sql 14 | 15 | script: 16 | 17 | - composer full-test 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | /* 2 | * ISC License 3 | * 4 | * Copyright (c) 2017 5 | * Paragon Initiative Enterprises 6 | * 7 | * Permission to use, copy, modify, and/or distribute this software for any 8 | * purpose with or without fee is hereby granted, provided that the above 9 | * copyright notice and this permission notice appear in all copies. 10 | * 11 | * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 12 | * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 13 | * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 14 | * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 15 | * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 16 | * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 17 | * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 18 | */ 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HERD 2 | 3 | [![Build Status](https://travis-ci.org/paragonie/herd.svg?branch=master)](https://travis-ci.org/paragonie/herd) 4 | [![Latest Stable Version](https://poser.pugx.org/paragonie/herd/v/stable)](https://packagist.org/packages/paragonie/herd) 5 | [![Latest Unstable Version](https://poser.pugx.org/paragonie/herd/v/unstable)](https://packagist.org/packages/paragonie/herd) 6 | [![License](https://poser.pugx.org/paragonie/herd/license)](https://packagist.org/packages/paragonie/herd) 7 | [![Downloads](https://img.shields.io/packagist/dt/paragonie/herd.svg)](https://packagist.org/packages/paragonie/herd) 8 | 9 | > **H**ash-**E**nsured **R**eplicated **D**atabase 10 | 11 | Herd processes [Chronicle](https://github.com/paragonie/chronicle) instances and 12 | maintains a local snapshot of the current state of affairs for: 13 | 14 | * Vendors 15 | * Public Keys 16 | * Products 17 | * Releases 18 | 19 | The **[Documentation](https://github.com/paragonie/herd/tree/master/doc)** is available online. 20 | 21 | ## Requirements 22 | 23 | * PHP 7.0+ (7.2 or newer are recommended) 24 | * Composer 25 | * PDO and a local database 26 | -------------------------------------------------------------------------------- /bin/shepherd: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | (... arguments) 25 | * 26 | * @var array $commandAliases 27 | */ 28 | $commandAliases = [ 29 | '' => Help::class, 30 | 'add-remote' => AddRemote::class, 31 | 'fact' => Fact::class, 32 | 'get-update' => GetUpdate::class, 33 | 'help' => Help::class, 34 | 'list-products' => ListProducts::class, 35 | 'list-updates' => ListUpdates::class, 36 | 'list-vendors' => ListVendors::class, 37 | 'review' => Review::class, 38 | 'transcribe' => Transcribe::class, 39 | 'vendor-keys' => VendorKeys::class 40 | ]; 41 | 42 | /** Do not touch the code below this line unless you absolutely must. **/ 43 | 44 | // Which command is being executed? 45 | if ($argv[0] === 'php') { 46 | $alias = $argc > 2 ? $argv[2] : ''; 47 | $args = \array_slice($argv, 3); 48 | } else { 49 | $alias = $argc > 1 ? $argv[1] : ''; 50 | $args = \array_slice($argv, 2); 51 | } 52 | 53 | if (!array_key_exists($alias, $commandAliases)) { 54 | echo 'Command not found!', PHP_EOL; 55 | exit(255); 56 | } 57 | $command = $commandAliases[$alias]; 58 | if (!\class_exists($command)) { 59 | echo 'Command not found! (Class does not exist.)', PHP_EOL; 60 | exit(255); 61 | } 62 | $class = new $command; 63 | if (!($class instanceof CommandInterface)) { 64 | echo 'Command not an instance of CommandInterface!', PHP_EOL; 65 | exit(255); 66 | } 67 | 68 | // Use GetOpt to process the arguments properly: 69 | $getOpt = new GetOpt($class->getOptions()); 70 | $getOpt->addOperands($class->getOperands()); 71 | try { 72 | $getOpt->process($args); 73 | } catch (\GetOpt\ArgumentException $ex) { 74 | echo 'Error (', \get_class($ex), '): ', 75 | $ex->getMessage(), PHP_EOL; 76 | exit(1); 77 | } 78 | /** @var array $opts */ 79 | $opts = $getOpt->getOptions(); 80 | $operands = $getOpt->getOperands(); 81 | 82 | // Execute the CLI command. 83 | $exitCode = $class 84 | ->setOpts($opts) 85 | ->run(...$operands); 86 | exit($exitCode); 87 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paragonie/herd", 3 | "description": "Hash-Ensured Replicated Database", 4 | "license": "ISC", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Paragon Initiative Enterprises", 9 | "email": "security@paragonie.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "ParagonIE\\Herd\\": "src/" 15 | } 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "ParagonIE\\Herd\\Tests\\": "tests/" 20 | } 21 | }, 22 | "require": { 23 | "php": "^7", 24 | "ext-json": "*", 25 | "guzzlehttp/guzzle": "^6", 26 | "paragonie/blakechain": "^1", 27 | "paragonie/certainty": "^2.5", 28 | "paragonie/constant_time_encoding": "^2", 29 | "paragonie/easydb": "^2.7", 30 | "paragonie/sapient": "^1", 31 | "paragonie/sodium_compat": "^1.8", 32 | "ulrichsg/getopt-php": "^3" 33 | }, 34 | "require-dev": { 35 | "phpunit/phpunit": "^6|^7", 36 | "phpstan/phpstan": "^0|^1", 37 | "vimeo/psalm": "^1|^2" 38 | }, 39 | "scripts": { 40 | "full-test": [ 41 | "@static-analysis", 42 | "@test", 43 | "@test-stan" 44 | ], 45 | "test-stan": "phpstan analyse --level=1 --no-progress src tests", 46 | "static-analysis": "psalm", 47 | "test": "phpunit" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /data/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/herd/5ae5b6e53cadfe9ec185e0f0fbd82311de608592/data/.gitkeep -------------------------------------------------------------------------------- /data/certs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paragonie/herd/5ae5b6e53cadfe9ec185e0f0fbd82311de608592/data/certs/.gitkeep -------------------------------------------------------------------------------- /data/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "core-vendor": "paragonie", 3 | "database": [], 4 | "policies": { 5 | "core-vendor-manage-keys-allow": false, 6 | "core-vendor-manage-keys-auto": false, 7 | "minimal-history": true, 8 | "quorum": 0, 9 | "tls-cert-dir": "" 10 | }, 11 | "remotes": [] 12 | } -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # What is Herd? 2 | 3 | Herd is a client-side library responsible for reading a Chronicle (or any number 4 | of replication instances of the Chronicle, chosen at random) and maintaining an 5 | always-up-to-date representation of the Chronicle's contents, specifically tailored 6 | for a trustless Public Key Infrastructure for software update signing. 7 | 8 | * [Command Line Interface](cli) 9 | * [Local configuration settings](config.md) 10 | * [Security Goals of HERD](SECURITY.md) 11 | 12 | 13 | -------------------------------------------------------------------------------- /doc/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Goals for HERD 2 | 3 | HERD relies on the immutable, append-only property of [Chronicle](https://github.com/paragonie/chronicle). 4 | 5 | Given the same Chronicle instances and local configuration file, the final 6 | database of public keys and update metadata HERD is deterministic. 7 | 8 | Third-party auditors **MUST** be able to rebuild the same database from scratch, 9 | compare it with the local collection, and verify that nothing has been tampered 10 | with locally. 11 | 12 | At an ecosystem level, ensuring that every end user sees the same history of 13 | public keys and software releases as part of an automatic security update feature 14 | provides a property similar to what biologists call *herd immunity*: 15 | 16 | In order to penetrate any system, you must first make *every* system vulnerable. 17 | -------------------------------------------------------------------------------- /doc/cli/README.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | The command line interface can be accessed by running `bin/shepherd` from the project's 4 | root directory, followed by a command. 5 | 6 | For example, `bin/shepherd help` invokes the `help` command. The full list of commands 7 | is available below: 8 | 9 | * [`add-remote`](#add-remote) 10 | * [`fact`](#fact) 11 | * [`get-update`](#get-update) 12 | * [`help`](#help) 13 | * [`list-products`](#list-products) 14 | * [`list-updates`](#list-updates) 15 | * [`list-vendors`](#list-vendors) 16 | * [`review`](#review) 17 | * [`transcribe`](#transcribe) 18 | * [`vendor-keys`](#vendor-keys) 19 | 20 | ## `add-remote` 21 | 22 | Adds a remote source to the configuration file. Returns a JSON blob. 23 | 24 | Usage: `shepherd add-remote ` 25 | 26 | Options: 27 | 28 | * `-c FILE` / `--config=FILE` specify the configuration file path 29 | * `-p` / `--primary` Mark the new Remote as a primary source (i.e. not a replica) 30 | 31 | ## `fact` 32 | 33 | Learn about a historical event. Returns a JSON blob. 34 | 35 | Usage: `shepherd fact ` 36 | 37 | Options: 38 | 39 | * `-c FILE` / `--config=FILE` specify the configuration file path 40 | * `-r` / `--remote` If the summary hash is not found, query a Remote source 41 | (i.e. a Chronicle instance) for this information instead 42 | 43 | ## `get-update` 44 | 45 | Get information about a particular software update. Returns a JSON blob. 46 | 47 | Usage: `shepherd get-update ` 48 | 49 | Options: 50 | 51 | * `-c FILE` / `--config=FILE` specify the configuration file path 52 | 53 | ## `help` 54 | 55 | Learn how to use each command available to this CLI API. Outputs human-readable 56 | text to the terminal window. 57 | 58 | (Effectively, it's the same as this page, except you can use it offline.) 59 | 60 | Usage: `shepherd help ` 61 | 62 | ## `list-products` 63 | 64 | List the products available for a given vendor. Returns a JSON blob. 65 | 66 | Usage: `shepherd list-products ` 67 | 68 | Options: 69 | 70 | * `-c FILE` / `--config=FILE` specify the configuration file path 71 | 72 | ## `list-updates` 73 | 74 | List the updates available for a given product. Returns a JSON blob. 75 | 76 | Usage: `shepherd list-updates ` 77 | 78 | Options: 79 | 80 | * `-c FILE` / `--config=FILE` specify the configuration file path 81 | 82 | ## `list-vendors` 83 | 84 | List/search vendors. The `search` parameter is optional. Returns a JSON blob. 85 | 86 | Usage: `shepherd list-vendors ?` 87 | 88 | Options: 89 | 90 | * `-c FILE` / `--config=FILE` specify the configuration file path 91 | 92 | ## `review` 93 | 94 | Review uncommitted updates. Interactive command. 95 | 96 | Usage: `shepherd review` 97 | 98 | Options: 99 | 100 | * `-c FILE` / `--config=FILE` specify the configuration file path 101 | 102 | ## `transcribe` 103 | 104 | Update local history from one or more of the remote Chronicles. 105 | 106 | Usage: `shepherd transcribe` 107 | 108 | Options: 109 | 110 | * `-c FILE` / `--config=FILE` specify the configuration file path 111 | 112 | The `transcribe` command should be run regularly (i.e. via cron jobs). 113 | 114 | ## `vendor-keys` 115 | 116 | List all the trusted public keys for a vendor. 117 | 118 | Usage: `shepherd vendor-keys` 119 | 120 | Options: 121 | 122 | * `-c FILE` / `--config=FILE` specify the configuration file path 123 | -------------------------------------------------------------------------------- /doc/config.md: -------------------------------------------------------------------------------- 1 | # Herd - Configuration 2 | 3 | The default configuration file lives in `data/config.json` from the project's root directory. 4 | However, you can override this when passing arguments to the [Command Line Interface](cli). 5 | 6 | A sample configuration file looks like: 7 | 8 | ```json 9 | { 10 | "core-vendor": "paragonie", 11 | "database": { 12 | "dsn": "sqlite:/home/user/.herd/local.sql", 13 | "username": "", 14 | "password": "", 15 | "options": [] 16 | }, 17 | "policies": { 18 | "core-vendor-manage-keys-allow": true, 19 | "core-vendor-manage-keys-auto": true, 20 | "minimal-history": true, 21 | "quorum": 2 22 | }, 23 | "remotes": [ 24 | { 25 | "url": "https://chronicle-public-test.paragonie.com/chronicle", 26 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 27 | "primary": true 28 | }, 29 | { 30 | "url": "https://localhost/chronicle/replica/foo", 31 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 32 | "primary": false 33 | }, 34 | { 35 | "url": "https://web-host-chronicle-mirror.example.com/chronicle/replica/bar", 36 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 37 | "primary": false 38 | }, 39 | { 40 | "url": "https://alternative-mirror.example.com/chronicle/replica/baz", 41 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 42 | "primary": false 43 | } 44 | ] 45 | } 46 | ``` 47 | 48 | * [`core-vendor`](#core-vendor) 49 | * [`database`](#database) 50 | * [`policies`](#policies) 51 | * [`core-vendor-manage-keys-allow`](#core-vendor-manage-keys-allow) 52 | * [`core-vendor-manage-keys-auto`](#core-vendor-manage-keys-auto) 53 | * [`minimal-history`](#minimal-history) 54 | * [`quorum`](#quorum) 55 | * [`remotes`](#remotes) 56 | 57 | ## Configuration Directives 58 | 59 | ### `core-vendor` 60 | 61 | > Type: `string` 62 | 63 | The `core-vendor` attribute is the name of the `vendor` who represents 64 | the project's core team. 65 | 66 | ### `database` 67 | 68 | > Type: `object` 69 | 70 | Configures the local database connection. If you're familiar with 71 | [PDO](https://secure.php.net/manual/en/class.pdo.php), this will already 72 | be familiar for you. 73 | 74 | Properties: 75 | 76 | * `dsn` (string) 77 | * `username` (string, optional) 78 | * `password` (string, optional) 79 | * `options` (array, optional) 80 | 81 | ### `policies` 82 | 83 | > Type: `object` 84 | 85 | #### `core-vendor-manage-keys-allow` 86 | 87 | > Type: `bool` 88 | 89 | Should the core vendor be allowed to manage keys for users? 90 | 91 | If you enable this option, and a vendor needs their signing keys replaced, 92 | the core vendor will be permitted to issue replacements. However, it will not 93 | be performed automatically, unless `core-vendor-manage-keys-auto` is also enabled. 94 | 95 | #### `core-vendor-manage-keys-auto` 96 | 97 | > Type: `bool` 98 | 99 | If `core-vendor-manage-keys-allow` is enabled, this applies key changes 100 | automatically when the core vendor signs them on behalf of vendors. 101 | 102 | If `core-vendor-manage-keys-allow` is disabled, this has no effect. 103 | 104 | #### `minimal-history` 105 | 106 | > Type: `bool` 107 | 108 | If enabled, the local history is automatically pruned of non-essential 109 | information to minimize disk space usage. 110 | 111 | Note that some commands (e.g. [`fact`](cli#fact)) will not work well if 112 | local history is cleared. 113 | 114 | #### `quorum` 115 | 116 | > Type: `int` 117 | 118 | The number of Chronicles that have to agree to seeing the same record 119 | before it's accepted locally. 120 | 121 | Cannot exceed the number of configured remotes. 122 | 123 | ### `remotes` 124 | 125 | > Type `array` 126 | 127 | This defines the remote sources (Chronicle instances) that Herd uses as 128 | its data source. Each array element is an object with the following 129 | properties: 130 | 131 | * `url` (string) is the URL of the Chronicle API (or replica). It must be a valid 132 | document root. 133 | * https://php-chronicle.pie-hosted.com/chronicle is **valid** 134 | * https://php-chronicle.pie-hosted.com/ is **invalid** 135 | * `public-key` (string) is the base64url-encoded Ed25519 public key for 136 | the Chronicle instance 137 | * `primary` (bool) indicates whether or not this is the primary source. 138 | Herd prioritizes secondary sources to minimize network usage (especially for 139 | the primary Chronicle). 140 | 141 | ### `tls-cert-dir` 142 | 143 | Directory which stores the CA-Cert.pem files, provided by 144 | [Certainty](https://github.com/paragonie/certainty). 145 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /sql/mysql/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE herd_history ( 2 | id BIGINT PRIMARY KEY AUTO_INCREMENT, 3 | hash TEXT, 4 | summaryhash TEXT, 5 | prevhash TEXT, 6 | contents TEXT, 7 | publickey TEXT, 8 | signature TEXT, 9 | accepted BOOLEAN DEFAULT FALSE, 10 | created DATETIME 11 | ); 12 | CREATE TABLE herd_vendors ( 13 | id BIGINT PRIMARY KEY AUTO_INCREMENT, 14 | name TEXT, 15 | created DATETIME, 16 | modified DATETIME 17 | ); 18 | CREATE TABLE herd_vendor_keys ( 19 | id BIGINT PRIMARY KEY AUTO_INCREMENT, 20 | trusted BOOLEAN DEFAULT FALSE, 21 | vendor BIGINT REFERENCES herd_vendors(id), 22 | publickey TEXT, 23 | history_create BIGINT, 24 | history_revoke BIGINT NULL, 25 | summaryhash_create TEXT, 26 | summaryhash_revoke TEXT NULL, 27 | name TEXT, 28 | created DATETIME, 29 | modified DATETIME 30 | ); 31 | CREATE TABLE herd_products ( 32 | id BIGINT PRIMARY KEY AUTO_INCREMENT, 33 | vendor BIGINT REFERENCES herd_vendors(id), 34 | name TEXT, 35 | created DATETIME, 36 | modified DATETIME 37 | ); 38 | CREATE TABLE herd_product_updates ( 39 | id BIGINT PRIMARY KEY AUTO_INCREMENT, 40 | product BIGINT REFERENCES herd_products(id), 41 | history BIGINT, 42 | summaryhash TEXT, 43 | version TEXT, 44 | body TEXT, 45 | publickey BIGINT REFERENCES herd_vendor_keys(id), 46 | signature TEXT, 47 | created DATETIME, 48 | modified DATETIME 49 | ); -------------------------------------------------------------------------------- /sql/pgsql/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE herd_history ( 2 | id BIGSERIAL PRIMARY KEY, 3 | hash TEXT, 4 | summaryhash TEXT, 5 | prevhash TEXT, 6 | contents TEXT, 7 | publickey TEXT, 8 | signature TEXT, 9 | accepted BOOLEAN DEFAULT FALSE, 10 | created TIMESTAMP 11 | ); 12 | CREATE TABLE herd_vendors ( 13 | id BIGSERIAL PRIMARY KEY, 14 | name TEXT, 15 | created TIMESTAMP, 16 | modified TIMESTAMP 17 | ); 18 | CREATE TABLE herd_vendor_keys ( 19 | id BIGSERIAL PRIMARY KEY, 20 | trusted BOOLEAN DEFAULT FALSE, 21 | vendor BIGINT REFERENCES herd_vendors(id), 22 | publickey TEXT, 23 | history_create BIGINT, 24 | history_revoke BIGINT NULL, 25 | summaryhash_create TEXT, 26 | summaryhash_revoke TEXT NULL, 27 | name TEXT, 28 | created TIMESTAMP, 29 | modified TIMESTAMP 30 | ); 31 | CREATE TABLE herd_products ( 32 | id BIGSERIAL PRIMARY KEY, 33 | vendor BIGINT REFERENCES herd_vendors(id), 34 | name TEXT, 35 | created TIMESTAMP, 36 | modified TIMESTAMP 37 | ); 38 | CREATE TABLE herd_product_updates ( 39 | id BIGSERIAL PRIMARY KEY, 40 | product BIGINT REFERENCES herd_products(id), 41 | history BIGINT, 42 | summaryhash TEXT, 43 | version TEXT, 44 | body TEXT, 45 | publickey BIGINT REFERENCES herd_vendor_keys(id), 46 | signature TEXT, 47 | created TIMESTAMP, 48 | modified TIMESTAMP 49 | ); 50 | -------------------------------------------------------------------------------- /sql/sqlite/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE herd_history ( 2 | id INTEGER PRIMARY KEY, 3 | hash TEXT, 4 | summaryhash TEXT, 5 | prevhash TEXT, 6 | contents TEXT, 7 | publickey TEXT, 8 | signature TEXT, 9 | accepted INT, 10 | created TEXT 11 | ); 12 | CREATE TABLE herd_vendors ( 13 | id INTEGER PRIMARY KEY, 14 | name TEXT, 15 | created TEXT, 16 | modified TEXT 17 | ); 18 | CREATE TABLE herd_vendor_keys ( 19 | id INTEGER PRIMARY KEY, 20 | trusted INT, 21 | vendor INTEGER REFERENCES herd_vendors(id), 22 | publickey TEXT, 23 | history_create INTEGER, 24 | history_revoke INTEGER, 25 | summaryhash_create TEXT, 26 | summaryhash_revoke TEXT NULL, 27 | name TEXT, 28 | created TEXT, 29 | modified TEXT 30 | ); 31 | CREATE TABLE herd_products ( 32 | id INTEGER PRIMARY KEY, 33 | vendor INTEGER REFERENCES herd_vendors(id), 34 | name TEXT, 35 | created TEXT, 36 | modified TEXT 37 | ); 38 | CREATE TABLE herd_product_updates ( 39 | id INTEGER PRIMARY KEY, 40 | product INTEGER REFERENCES herd_products(id), 41 | history INTEGER, 42 | summaryhash TEXT, 43 | version TEXT, 44 | body TEXT, 45 | publickey INTEGER REFERENCES herd_vendor_keys(id), 46 | signature TEXT, 47 | created TEXT, 48 | modified TEXT 49 | ); 50 | -------------------------------------------------------------------------------- /src/CommandLine/Command/AddRemote.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public function getOptions(): array 34 | { 35 | return [ 36 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT), 37 | new Option('p', 'primary', GetOpt::NO_ARGUMENT) 38 | ]; 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function getOperands(): array 45 | { 46 | return [ 47 | new Operand('url', Operand::REQUIRED), 48 | new Operand('publickey', Operand::REQUIRED) 49 | ]; 50 | } 51 | 52 | /** 53 | * @param array $args 54 | * @return int 55 | * @throws EncodingError 56 | * @throws FilesystemException 57 | */ 58 | public function run(...$args): int 59 | { 60 | /** 61 | * @var string $url 62 | * @var string $publickey 63 | */ 64 | list ($url, $publickey) = $args; 65 | 66 | $file = \file_get_contents($this->configPath); 67 | if (!\is_string($file)) { 68 | throw new FilesystemException('Could not read configuration file'); 69 | } 70 | /** @var array>> $decoded */ 71 | $decoded = \json_decode($file, true); 72 | if (!\is_array($decoded)) { 73 | throw new EncodingError('Could not decode JSON body in configuration file'); 74 | } 75 | $pkey = (string) Base64UrlSafe::decode($publickey); 76 | if (Binary::safeStrlen($pkey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { 77 | throw new EncodingError('Public key is not a base64url-encoded Ed25519 public key, but it must be.'); 78 | } 79 | 80 | $decoded['remotes'][] = [ 81 | 'url' => $url, 82 | 'public-key' => $publickey, 83 | 'primary' => $this->primary 84 | ]; 85 | 86 | $encoded = \json_encode($decoded, JSON_PRETTY_PRINT); 87 | if (!\is_string($encoded)) { 88 | throw new EncodingError('Could not re-encode JSON body'); 89 | } 90 | 91 | /** @var bool|int $saved */ 92 | $saved = \file_put_contents($this->configPath, $encoded); 93 | if (!\is_int($saved)) { 94 | throw new FilesystemException('Could not write to configuration file'); 95 | } 96 | return 0; 97 | } 98 | 99 | /** 100 | * Use the options provided by GetOpt to populate class properties 101 | * for this Command object. 102 | * 103 | * @param array $args 104 | * @return self 105 | * @throws FilesystemException 106 | */ 107 | public function setOpts(array $args = []) 108 | { 109 | if (isset($args['config'])) { 110 | $this->setConfigPath($args['config']); 111 | } elseif (isset($args['c'])) { 112 | $this->setConfigPath($args['c']); 113 | } else { 114 | $this->setConfigPath( 115 | \dirname(\dirname(\dirname(__DIR__))) . 116 | '/data/config.json' 117 | ); 118 | } 119 | 120 | if (isset($args['primary'])) { 121 | $this->primary = true; 122 | } elseif (isset($args['p'])) { 123 | $this->primary = true; 124 | } 125 | 126 | return $this; 127 | } 128 | 129 | /** 130 | * Get information about how this command should be used. 131 | * 132 | * @return array 133 | */ 134 | public function usageInfo(): array 135 | { 136 | return [ 137 | 'name' => 'Add remote source', 138 | 'usage' => 'shepherd add-remote ', 139 | 'options' => [ 140 | 'Configuration file' => [ 141 | 'Examples' => [ 142 | '-c /path/to/file', 143 | '--config=/path/to/file' 144 | ] 145 | ], 146 | 'Primary?' => [ 147 | 'Examples' => [ 148 | '-p', 149 | '--primary' 150 | ] 151 | ] 152 | ] 153 | ]; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/CommandLine/Command/Fact.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | public function getOptions(): array 42 | { 43 | return [ 44 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT), 45 | new Option('r', 'remote', GetOpt::NO_ARGUMENT) 46 | ]; 47 | } 48 | 49 | /** 50 | * @return array 51 | */ 52 | public function getOperands(): array 53 | { 54 | return [ 55 | new Operand('hash', Operand::REQUIRED) 56 | ]; 57 | } 58 | 59 | /** 60 | * @param array $args 61 | * @return int 62 | * @throws \Exception 63 | * @throws \Error 64 | */ 65 | public function run(...$args): int 66 | { 67 | /** @var string $arg1 */ 68 | $arg1 = \array_shift($args); 69 | 70 | $db = $this->getDatabase($this->configPath); 71 | /** @var array $data */ 72 | $data = $db->row( 73 | "SELECT * FROM herd_history WHERE summaryhash = ?", 74 | $arg1 75 | ); 76 | if (empty($data)) { 77 | if (!$this->remoteSearch) { 78 | echo '[]', PHP_EOL; 79 | exit(2); 80 | } 81 | $data = $this->lookup($db, $arg1); 82 | if (empty($data)) { 83 | echo '[]', PHP_EOL; 84 | exit(2); 85 | } 86 | $data['notice'] = 'This came from a remote source, and is not stored locally!'; 87 | } 88 | 89 | // We don't need this to display: 90 | unset($data['id']); 91 | 92 | // Convert to bool 93 | $data['accepted'] = !empty($data['accepted']); 94 | 95 | /** @var string $encoded */ 96 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 97 | if (!\is_string($encoded)) { 98 | throw new EncodingError('Could not encode fact into a JSON string'); 99 | } 100 | echo $encoded, PHP_EOL; 101 | return 0; 102 | } 103 | 104 | /** 105 | * @param EasyDB $db 106 | * @param string $summaryHash 107 | * @return array 108 | * 109 | * @throws EmptyValueException 110 | * @throws EncodingError 111 | * @throws FilesystemException 112 | */ 113 | protected function lookup(EasyDB $db, string $summaryHash): array 114 | { 115 | $herd = new Herd( 116 | new Local($db), 117 | Config::fromFile($this->configPath) 118 | ); 119 | $remote = $herd->selectRemote(true); 120 | $sapient = new Sapient(); 121 | try { 122 | /** @var array $decoded */ 123 | $decoded = $sapient->decodeSignedJsonResponse( 124 | $remote->lookup($summaryHash), 125 | $remote->getPublicKey() 126 | ); 127 | if ($decoded['status'] === 'OK') { 128 | if (!\is_array($decoded['results'])) { 129 | return []; 130 | } 131 | return (array) \array_shift($decoded['results']); 132 | } 133 | } catch (\Throwable $ex) { 134 | } 135 | return []; 136 | } 137 | 138 | /** 139 | * Use the options provided by GetOpt to populate class properties 140 | * for this Command object. 141 | * 142 | * @param array $args 143 | * @return self 144 | * @throws \Exception 145 | */ 146 | public function setOpts(array $args = []) 147 | { 148 | if (isset($args['config'])) { 149 | $this->setConfigPath($args['config']); 150 | } elseif (isset($args['c'])) { 151 | $this->setConfigPath($args['c']); 152 | } else { 153 | $this->setConfigPath( 154 | \dirname(\dirname(\dirname(__DIR__))) . 155 | '/data/config.json' 156 | ); 157 | } 158 | if (isset($args['remote'])) { 159 | $this->remoteSearch = !empty($args['remote']); 160 | } elseif (isset($args['r'])) { 161 | $this->remoteSearch = !empty($args['r']); 162 | } 163 | return $this; 164 | } 165 | 166 | /** 167 | * Get information about how this command should be used. 168 | * 169 | * @return array 170 | */ 171 | public function usageInfo(): array 172 | { 173 | return [ 174 | 'name' => 'Learn about a historical event.', 175 | 'usage' => 'shepherd fact ', 176 | 'options' => [ 177 | 'Configuration file' => [ 178 | 'Examples' => [ 179 | '-c /path/to/file', 180 | '--config=/path/to/file' 181 | ] 182 | ], 183 | 'Remote lookup?' => [ 184 | 'Info' => 'If there is no local data, look it up from a remote source.', 185 | 'Examples' => [ 186 | '-r', 187 | '--remote' 188 | ] 189 | ] 190 | ] 191 | ]; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/CommandLine/Command/GetUpdate.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getOptions(): array 30 | { 31 | return [ 32 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 33 | ]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getOperands(): array 40 | { 41 | return [ 42 | new Operand('vendor', Operand::REQUIRED), 43 | new Operand('product', Operand::REQUIRED), 44 | new Operand('version', Operand::REQUIRED) 45 | ]; 46 | } 47 | 48 | /** 49 | * @param array $args 50 | * @return int 51 | * @throws \Exception 52 | * @throws \Error 53 | */ 54 | public function run(...$args): int 55 | { 56 | /** 57 | * @var string $vendor 58 | * @var string $product 59 | * @var string $version 60 | */ 61 | list($vendor, $product, $version) = $args; 62 | $db = $this->getDatabase($this->configPath); 63 | 64 | /** @var array $data */ 65 | $data = $db->row( 66 | " 67 | SELECT 68 | v.name AS vendor, 69 | p.name AS product, 70 | h.contents AS history, 71 | u.* 72 | FROM 73 | herd_vendors v 74 | LEFT JOIN 75 | herd_products p ON p.vendor = v.id 76 | LEFT JOIN 77 | herd_product_updates u ON u.product = p.id 78 | WHERE 79 | v.name = ? 80 | AND p.name = ? 81 | AND u.version = ? 82 | ", 83 | $vendor, 84 | $product, 85 | $version 86 | ); 87 | if (empty($data)) { 88 | echo '[]', PHP_EOL; 89 | exit(2); 90 | } 91 | 92 | /** @var string $encoded */ 93 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 94 | if (!\is_string($encoded)) { 95 | throw new EncodingError('Could not encode fact into a JSON string'); 96 | } 97 | echo $encoded, PHP_EOL; 98 | return 0; 99 | } 100 | 101 | /** 102 | * Use the options provided by GetOpt to populate class properties 103 | * for this Command object. 104 | * 105 | * @param array $args 106 | * @return self 107 | * @throws \Exception 108 | */ 109 | public function setOpts(array $args = []) 110 | { 111 | if (isset($args['config'])) { 112 | $this->setConfigPath($args['config']); 113 | } elseif (isset($args['c'])) { 114 | $this->setConfigPath($args['c']); 115 | } else { 116 | $this->setConfigPath( 117 | \dirname(\dirname(\dirname(__DIR__))) . 118 | '/data/config.json' 119 | ); 120 | } 121 | return $this; 122 | } 123 | 124 | /** 125 | * Get information about how this command should be used. 126 | * 127 | * @return array 128 | */ 129 | public function usageInfo(): array 130 | { 131 | return [ 132 | 'name' => 'Get update information', 133 | 'usage' => 'shepherd get-update ', 134 | 'options' => [ 135 | 'Configuration file' => [ 136 | 'Examples' => [ 137 | '-c /path/to/file', 138 | '--config=/path/to/file' 139 | ] 140 | ] 141 | ] 142 | ]; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/CommandLine/Command/Help.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | public function getOptions(): array 29 | { 30 | return []; 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function getOperands(): array 37 | { 38 | return []; 39 | } 40 | 41 | /** 42 | * @return array 43 | */ 44 | public function getCommands(): array 45 | { 46 | /** @var array $aliases */ 47 | $aliases = $GLOBALS['commandAliases']; 48 | $map = []; 49 | foreach ($aliases as $command => $className) { 50 | if (empty($className) || empty($command)) { 51 | continue; 52 | } 53 | if ($className === __CLASS__) { 54 | $usageInfo = $this->usageInfo(); 55 | } else { 56 | /** @var CommandInterface $class */ 57 | $class = new $className; 58 | if (!($class instanceof CommandInterface)) { 59 | continue; 60 | } 61 | $usageInfo = $class->usageInfo(); 62 | } 63 | $map[$command] = $usageInfo; 64 | } 65 | return $map; 66 | } 67 | 68 | /** 69 | * @return int 70 | */ 71 | public function listCommands(): int 72 | { 73 | $commands = $this->getCommands(); 74 | $maxLength = 7; 75 | /** @var string $key */ 76 | foreach (\array_keys($commands) as $key) { 77 | $maxLength = \max($maxLength, \strlen($key)); 78 | } 79 | /** 80 | * @var array $size 81 | * @var int $w 82 | */ 83 | $size = $this->getScreenSize(); 84 | $w = (int) \array_shift($size); 85 | 86 | // Header 87 | echo \str_pad('Command', $maxLength + 2, ' ', STR_PAD_RIGHT), 88 | '| Information', 89 | PHP_EOL; 90 | 91 | /** 92 | * @var string $command 93 | * @var array $usageInfo 94 | */ 95 | foreach ($commands as $command => $usageInfo) { 96 | echo \str_repeat('-', $maxLength + 2), 97 | '+', 98 | \str_repeat('-', $w - $maxLength - 3), 99 | PHP_EOL; 100 | echo \str_pad($command, $maxLength + 2, ' ', STR_PAD_RIGHT), 101 | '| '; 102 | 103 | // Display format. Temporarily, JSON. 104 | /** @var string $encoded */ 105 | $encoded = $usageInfo['name'] ?? $command; 106 | $encoded .= PHP_EOL . PHP_EOL; 107 | $encoded .= (string) ($usageInfo['usage'] ?? ('shepherd ' . $command)); 108 | 109 | $usage = \explode(PHP_EOL, $encoded); 110 | echo \implode(PHP_EOL . \str_repeat(' ', $maxLength + 2 ). '| ', $usage); 111 | echo PHP_EOL; 112 | } 113 | return 0; 114 | } 115 | 116 | /** 117 | * @param string $arg 118 | * @return int 119 | */ 120 | public function getCommandInfo(string $arg): int 121 | { 122 | $commands = $this->getCommands(); 123 | if (!\array_key_exists($arg, $commands)) { 124 | echo 'Command not found', PHP_EOL; 125 | return 2; 126 | } 127 | 128 | $maxLength = 7; 129 | /** @var string $key */ 130 | foreach (\array_keys($commands) as $key) { 131 | $maxLength = \max($maxLength, \strlen($key)); 132 | } 133 | /** 134 | * @var array $size 135 | * @var int $w 136 | */ 137 | $size = $this->getScreenSize(); 138 | $w = (int) \array_shift($size); 139 | 140 | // Header 141 | echo \str_pad('Command', $maxLength + 2, ' ', STR_PAD_RIGHT), 142 | '| Information', 143 | PHP_EOL; 144 | 145 | /** 146 | * @var string $command 147 | * @var array $usageInfo 148 | */ 149 | $usageInfo = $commands[$arg]; 150 | echo \str_repeat('-', $maxLength + 2), 151 | '+', 152 | \str_repeat('-', $w - $maxLength - 3), 153 | PHP_EOL; 154 | echo \str_pad($arg, $maxLength + 2, ' ', STR_PAD_RIGHT), 155 | '| '; 156 | 157 | // Display format. Temporarily, JSON. 158 | $encoded = \json_encode($usageInfo, JSON_PRETTY_PRINT); 159 | if (!\is_string($encoded)) { 160 | return 1; 161 | } 162 | if ($encoded === '[]') { 163 | echo '(No help information available.)', PHP_EOL; 164 | return 1; 165 | } 166 | $usage = \explode(PHP_EOL, $encoded); 167 | echo \implode(PHP_EOL . \str_repeat(' ', $maxLength + 2 ). '| ', $usage); 168 | echo PHP_EOL; 169 | return 0; 170 | } 171 | 172 | /** 173 | * @param array $args 174 | * @return int 175 | */ 176 | public function run(...$args): int 177 | { 178 | if (empty($args)) { 179 | return $this->listCommands(); 180 | } 181 | /** @var string $key */ 182 | $key = \array_shift($args); 183 | return $this->getCommandInfo($key); 184 | } 185 | 186 | /** 187 | * Use the options provided by GetOpt to populate class properties 188 | * for this Command object. 189 | * 190 | * @param array $args 191 | * @return self 192 | */ 193 | public function setOpts(array $args = []) 194 | { 195 | return $this; 196 | } 197 | 198 | /** 199 | * Get information about how this command should be used. 200 | * 201 | * @return array 202 | */ 203 | public function usageInfo(): array 204 | { 205 | return [ 206 | 'name' => 'Usage Information', 207 | 'details' => 'Learn how to use each command available to this CLI API.' 208 | ]; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/CommandLine/Command/ListProducts.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getOptions(): array 30 | { 31 | return [ 32 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 33 | ]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getOperands(): array 40 | { 41 | return [ 42 | new Operand('vendor', Operand::REQUIRED) 43 | ]; 44 | } 45 | 46 | /** 47 | * @param array $args 48 | * @return int 49 | * @throws \Exception 50 | * @throws \Error 51 | */ 52 | public function run(...$args): int 53 | { 54 | /** 55 | * @var string $vendor 56 | */ 57 | $vendor = \array_shift($args); 58 | $db = $this->getDatabase($this->configPath); 59 | 60 | /** @var array $data */ 61 | $data = $db->run( 62 | " 63 | SELECT 64 | v.name AS vendor, 65 | p.name AS product, 66 | p.created, 67 | p.modified 68 | FROM 69 | herd_vendors v 70 | LEFT JOIN 71 | herd_products p ON p.vendor = v.id 72 | WHERE 73 | v.name = ? 74 | ", 75 | $vendor 76 | ); 77 | if (empty($data)) { 78 | echo '[]', PHP_EOL; 79 | exit(2); 80 | } 81 | 82 | /** @var string $encoded */ 83 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 84 | if (!\is_string($encoded)) { 85 | throw new EncodingError('Could not encode fact into a JSON string'); 86 | } 87 | echo $encoded, PHP_EOL; 88 | return 0; 89 | } 90 | 91 | /** 92 | * Use the options provided by GetOpt to populate class properties 93 | * for this Command object. 94 | * 95 | * @param array $args 96 | * @return self 97 | * @throws \Exception 98 | */ 99 | public function setOpts(array $args = []) 100 | { 101 | if (isset($args['config'])) { 102 | $this->setConfigPath($args['config']); 103 | } elseif (isset($args['c'])) { 104 | $this->setConfigPath($args['c']); 105 | } else { 106 | $this->setConfigPath( 107 | \dirname(\dirname(\dirname(__DIR__))) . 108 | '/data/config.json' 109 | ); 110 | } 111 | return $this; 112 | } 113 | 114 | /** 115 | * Get information about how this command should be used. 116 | * 117 | * @return array 118 | */ 119 | public function usageInfo(): array 120 | { 121 | return [ 122 | 'name' => 'List products for vendor', 123 | 'usage' => 'shepherd list-products ', 124 | 'options' => [ 125 | 'Configuration file' => [ 126 | 'Examples' => [ 127 | '-c /path/to/file', 128 | '--config=/path/to/file' 129 | ] 130 | ] 131 | ] 132 | ]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/CommandLine/Command/ListUpdates.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getOptions(): array 30 | { 31 | return [ 32 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 33 | ]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getOperands(): array 40 | { 41 | return [ 42 | new Operand('vendor', Operand::REQUIRED), 43 | new Operand('product', Operand::REQUIRED), 44 | new Operand('search', Operand::OPTIONAL) 45 | ]; 46 | } 47 | 48 | /** 49 | * @param array $args 50 | * @return int 51 | * @throws \Exception 52 | * @throws \Error 53 | */ 54 | public function run(...$args): int 55 | { 56 | $db = $this->getDatabase($this->configPath); 57 | $query = " 58 | SELECT 59 | v.name AS vendor, 60 | p.name AS product, 61 | u.* 62 | FROM 63 | herd_vendors v 64 | LEFT JOIN 65 | herd_products p ON p.vendor = v.id 66 | LEFT JOIN 67 | herd_product_updates u ON u.product = p.id 68 | WHERE 69 | v.name = ? 70 | AND p.name = ? 71 | "; 72 | /** 73 | * @var string $vendor 74 | * @var string $product 75 | */ 76 | if (count($args) > 2) { 77 | /** @var string $search */ 78 | list($vendor, $product, $search) = $args; 79 | /** @var array $data */ 80 | $data = $db->run( 81 | $query . ' AND u.version LIKE ?', 82 | $vendor, 83 | $product, 84 | $db->escapeLikeValue($search) . '%' 85 | ); 86 | } else { 87 | list($vendor, $product) = $args; 88 | /** @var array $data */ 89 | $data = $db->run( 90 | $query, 91 | $vendor, 92 | $product 93 | ); 94 | } 95 | if (empty($data)) { 96 | echo '[]', PHP_EOL; 97 | exit(2); 98 | } 99 | 100 | /** @var string $encoded */ 101 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 102 | if (!\is_string($encoded)) { 103 | throw new EncodingError('Could not encode fact into a JSON string'); 104 | } 105 | echo $encoded, PHP_EOL; 106 | return 0; 107 | } 108 | 109 | /** 110 | * Use the options provided by GetOpt to populate class properties 111 | * for this Command object. 112 | * 113 | * @param array $args 114 | * @return self 115 | * @throws \Exception 116 | */ 117 | public function setOpts(array $args = []) 118 | { 119 | if (isset($args['config'])) { 120 | $this->setConfigPath($args['config']); 121 | } elseif (isset($args['c'])) { 122 | $this->setConfigPath($args['c']); 123 | } else { 124 | $this->setConfigPath( 125 | \dirname(\dirname(\dirname(__DIR__))) . 126 | '/data/config.json' 127 | ); 128 | } 129 | return $this; 130 | } 131 | 132 | /** 133 | * Get information about how this command should be used. 134 | * 135 | * @return array 136 | */ 137 | public function usageInfo(): array 138 | { 139 | return [ 140 | 'name' => 'List updates for product', 141 | 'usage' => 'shepherd list-updates ', 142 | 'options' => [ 143 | 'Configuration file' => [ 144 | 'Examples' => [ 145 | '-c /path/to/file', 146 | '--config=/path/to/file' 147 | ] 148 | ] 149 | ] 150 | ]; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/CommandLine/Command/ListVendors.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getOptions(): array 30 | { 31 | return [ 32 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 33 | ]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getOperands(): array 40 | { 41 | return [ 42 | new Operand('search', Operand::OPTIONAL) 43 | ]; 44 | } 45 | 46 | /** 47 | * @param array $args 48 | * @return int 49 | * @throws \Exception 50 | * @throws \Error 51 | */ 52 | public function run(...$args): int 53 | { 54 | $db = $this->getDatabase($this->configPath); 55 | if (empty($args)) { 56 | /** @var array $data */ 57 | $data = $db->run( 58 | " 59 | SELECT 60 | * 61 | FROM 62 | herd_vendors 63 | ORDER BY name ASC 64 | " 65 | ); 66 | } else { 67 | /** @var string $arg1 */ 68 | $arg1 = \array_shift($args); 69 | 70 | /** @var array $data */ 71 | $data = $db->run( 72 | " 73 | SELECT 74 | * 75 | FROM 76 | herd_vendors 77 | WHERE 78 | name LIKE ? 79 | ORDER BY name ASC 80 | ", 81 | $db->escapeLikeValue($arg1) . '%' 82 | ); 83 | } 84 | 85 | if (empty($data)) { 86 | echo '[]', PHP_EOL; 87 | exit(2); 88 | } 89 | 90 | /** @var string $encoded */ 91 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 92 | if (!\is_string($encoded)) { 93 | throw new EncodingError('Could not encode vendor list into a JSON string'); 94 | } 95 | echo $encoded, PHP_EOL; 96 | return 0; 97 | } 98 | 99 | /** 100 | * Use the options provided by GetOpt to populate class properties 101 | * for this Command object. 102 | * 103 | * @param array $args 104 | * @return self 105 | * @throws \Exception 106 | */ 107 | public function setOpts(array $args = []) 108 | { 109 | if (isset($args['config'])) { 110 | $this->setConfigPath($args['config']); 111 | } elseif (isset($args['c'])) { 112 | $this->setConfigPath($args['c']); 113 | } else { 114 | $this->setConfigPath( 115 | \dirname(\dirname(\dirname(__DIR__))) . 116 | '/data/config.json' 117 | ); 118 | } 119 | return $this; 120 | } 121 | 122 | /** 123 | * Get information about how this command should be used. 124 | * 125 | * @return array 126 | */ 127 | public function usageInfo(): array 128 | { 129 | return [ 130 | 'name' => 'List/search vendors', 131 | 'usage' => 'shepherd list-vendors ?', 132 | 'options' => [ 133 | 'Configuration file' => [ 134 | 'Examples' => [ 135 | '-c /path/to/file', 136 | '--config=/path/to/file' 137 | ] 138 | ] 139 | ] 140 | ]; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/CommandLine/Command/Review.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public function getOptions(): array 34 | { 35 | return [ 36 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function getOperands(): array 44 | { 45 | return []; 46 | } 47 | 48 | /** 49 | * @param array $args 50 | * @return int 51 | * @throws \Exception 52 | * @throws \Error 53 | */ 54 | public function run(...$args): int 55 | { 56 | $db = $this->getDatabase($this->configPath); 57 | $herd = new Herd( 58 | new Local($db), 59 | Config::fromFile($this->configPath) 60 | ); 61 | $history = new History($herd); 62 | 63 | $query = " SELECT * FROM herd_history WHERE "; 64 | if ($db->getDriver() === 'sqlite') { 65 | $query .= 'accepted = 0'; 66 | } else { 67 | $query .= 'NOT accepted'; 68 | } 69 | 70 | /** @var array> $data */ 71 | $data = $db->run($query); 72 | if (empty($data)) { 73 | echo 'No uncommitted entries in the local history.', PHP_EOL; 74 | return 0; 75 | } 76 | return $this->reviewHistory($history, $data); 77 | } 78 | 79 | /** 80 | * @param History $history 81 | * @param array> $data 82 | * @return int 83 | */ 84 | protected function reviewHistory(History $history, array $data): int 85 | { 86 | /** 87 | * @var array $size 88 | * @var int $w 89 | * @var array $row 90 | */ 91 | $size = $this->getScreenSize(); 92 | $w = (int) \array_shift($size); 93 | foreach ($data as $row) { 94 | echo \str_repeat('-', $w - 1), PHP_EOL; 95 | echo '-- Hash: ', $row['hash'], PHP_EOL; 96 | echo '-- Summary Hash: ', $row['summaryhash'], PHP_EOL; 97 | echo '-- Created: ', $row['created'], PHP_EOL; 98 | echo '-- Contents:', PHP_EOL; 99 | echo $row['contents'], PHP_EOL; 100 | echo \str_repeat('-', $w - 1), PHP_EOL; 101 | $response = $this->prompt('Accept these changes? (y/N)'); 102 | 103 | switch (\strtolower($response)) { 104 | case 'y': 105 | case 'yes': 106 | try { 107 | $history->parseContentsAndInsert( 108 | $row['contents'], 109 | (int)$row['id'], 110 | $row['summaryhash'], 111 | true /* We are overriding the normal behavior. */ 112 | ); 113 | } catch (\Throwable $ex) { 114 | echo $ex->getMessage(), PHP_EOL; 115 | return 1; 116 | } 117 | echo 'Change accepted.', PHP_EOL; 118 | break; 119 | default: 120 | echo 'Change rejected!', PHP_EOL; 121 | } 122 | } 123 | return 0; 124 | } 125 | 126 | /** 127 | * Use the options provided by GetOpt to populate class properties 128 | * for this Command object. 129 | * 130 | * @param array $args 131 | * @return self 132 | * @throws \Exception 133 | */ 134 | public function setOpts(array $args = []) 135 | { 136 | if (isset($args['config'])) { 137 | $this->setConfigPath($args['config']); 138 | } elseif (isset($args['c'])) { 139 | $this->setConfigPath($args['c']); 140 | } else { 141 | $this->setConfigPath( 142 | \dirname(\dirname(\dirname(__DIR__))) . 143 | '/data/config.json' 144 | ); 145 | } 146 | return $this; 147 | } 148 | 149 | /** 150 | * Get information about how this command should be used. 151 | * 152 | * @return array 153 | */ 154 | public function usageInfo(): array 155 | { 156 | return [ 157 | 'name' => 'Review uncommitted updates', 158 | 'usage' => 'shepherd review', 159 | 'options' => [ 160 | 'Configuration file' => [ 161 | 'Examples' => [ 162 | '-c /path/to/file', 163 | '--config=/path/to/file' 164 | ] 165 | ] 166 | ] 167 | ]; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/CommandLine/Command/Transcribe.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | public function getOptions(): array 37 | { 38 | return [ 39 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 40 | ]; 41 | } 42 | 43 | /** 44 | * @return array 45 | */ 46 | public function getOperands(): array 47 | { 48 | return []; 49 | } 50 | 51 | /** 52 | * @param array $args 53 | * @return int 54 | * @throws EncodingError 55 | * @throws FilesystemException 56 | */ 57 | public function run(...$args): int 58 | { 59 | $history = new History( 60 | new Herd( 61 | new Local($this->getDatabase($this->configPath)), 62 | Config::fromFile($this->configPath) 63 | ) 64 | ); 65 | try { 66 | $history->transcribe(); 67 | echo 'OK', PHP_EOL; 68 | } catch (\Throwable $ex) { 69 | echo $ex->getMessage(), PHP_EOL; 70 | return 127; 71 | } 72 | 73 | return 0; 74 | } 75 | 76 | /** 77 | * Use the options provided by GetOpt to populate class properties 78 | * for this Command object. 79 | * 80 | * @param array $args 81 | * @return self 82 | * @throws \Exception 83 | */ 84 | public function setOpts(array $args = []) 85 | { 86 | if (isset($args['config'])) { 87 | $this->setConfigPath($args['config']); 88 | } elseif (isset($args['c'])) { 89 | $this->setConfigPath($args['c']); 90 | } else { 91 | $this->setConfigPath( 92 | \dirname(\dirname(\dirname(__DIR__))) . 93 | '/data/config.json' 94 | ); 95 | } 96 | return $this; 97 | } 98 | 99 | /** 100 | * Get information about how this command should be used. 101 | * 102 | * @return array 103 | */ 104 | public function usageInfo(): array 105 | { 106 | return [ 107 | 'name' => 'Update local history from Chronicle', 108 | 'usage' => 'shepherd transcribe', 109 | 'options' => [ 110 | 'Configuration file' => [ 111 | 'Examples' => [ 112 | '-c /path/to/file', 113 | '--config=/path/to/file' 114 | ] 115 | ] 116 | ] 117 | ]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/CommandLine/Command/VendorKeys.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | public function getOptions(): array 30 | { 31 | return [ 32 | new Option('c', 'config', GetOpt::REQUIRED_ARGUMENT) 33 | ]; 34 | } 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function getOperands(): array 40 | { 41 | return [ 42 | new Operand('vendor', Operand::REQUIRED) 43 | ]; 44 | } 45 | 46 | /** 47 | * @param array $args 48 | * @return int 49 | * @throws \Exception 50 | * @throws \Error 51 | */ 52 | public function run(...$args): int 53 | { 54 | /** 55 | * @var string $vendor 56 | */ 57 | $vendor = \array_shift($args); 58 | $db = $this->getDatabase($this->configPath); 59 | 60 | /** @var array $data */ 61 | $data = $db->run( 62 | " 63 | SELECT 64 | v.name AS vendor, 65 | k.publickey, 66 | k.name, 67 | k.created, 68 | k.modified 69 | k.summaryhash_create 70 | FROM 71 | herd_vendors v 72 | LEFT JOIN 73 | herd_vendor_keys k ON k.vendor = v.id 74 | WHERE 75 | v.name = ? 76 | AND k.trusted 77 | ", 78 | $vendor 79 | ); 80 | if (empty($data)) { 81 | echo '[]', PHP_EOL; 82 | exit(2); 83 | } 84 | 85 | /** @var string $encoded */ 86 | $encoded = \json_encode($data, JSON_PRETTY_PRINT); 87 | if (!\is_string($encoded)) { 88 | throw new EncodingError('Could not encode fact into a JSON string'); 89 | } 90 | echo $encoded, PHP_EOL; 91 | return 0; 92 | } 93 | 94 | /** 95 | * Use the options provided by GetOpt to populate class properties 96 | * for this Command object. 97 | * 98 | * @param array $args 99 | * @return self 100 | * @throws \Exception 101 | */ 102 | public function setOpts(array $args = []) 103 | { 104 | if (isset($args['config'])) { 105 | $this->setConfigPath($args['config']); 106 | } elseif (isset($args['c'])) { 107 | $this->setConfigPath($args['c']); 108 | } else { 109 | $this->setConfigPath( 110 | \dirname(\dirname(\dirname(__DIR__))) . 111 | '/data/config.json' 112 | ); 113 | } 114 | return $this; 115 | } 116 | 117 | /** 118 | * Get information about how this command should be used. 119 | * 120 | * @return array 121 | */ 122 | public function usageInfo(): array 123 | { 124 | return [ 125 | 'name' => 'List public keys for vendor', 126 | 'usage' => 'shepherd vendor-keys ', 127 | 'options' => [ 128 | 'Configuration file' => [ 129 | 'Examples' => [ 130 | '-c /path/to/file', 131 | '--config=/path/to/file' 132 | ] 133 | ] 134 | ] 135 | ]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/CommandLine/CommandInterface.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function getOptions(): array; 20 | 21 | /** 22 | * @return array 23 | */ 24 | public function getOperands(): array; 25 | 26 | /** 27 | * @param array $args 28 | * @return int 29 | */ 30 | public function run(...$args): int; 31 | 32 | /** 33 | * Use the options provided by GetOpt to populate class properties 34 | * for this Command object. 35 | * 36 | * @param array $args 37 | * @return self 38 | */ 39 | public function setOpts(array $args = []); 40 | 41 | /** 42 | * Get information about how this command should be used. 43 | * 44 | * @return array 45 | */ 46 | public function usageInfo(): array; 47 | } 48 | -------------------------------------------------------------------------------- /src/CommandLine/ConfigurableTrait.php: -------------------------------------------------------------------------------- 1 | configPath = \realpath($path); 32 | return $this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/CommandLine/DatabaseTrait.php: -------------------------------------------------------------------------------- 1 | > $data */ 42 | $data = \json_decode($config, true); 43 | if (!\is_array($data)) { 44 | throw new EncodingError('Could not decode JSON file'); 45 | } 46 | if (empty($data['database']['dsn'])) { 47 | return Factory::create('sqlite::memory:'); 48 | } 49 | if ($data['database']['dsn'] === 'sqlite') { 50 | return Factory::create((string) $data['database']['dsn']); 51 | } 52 | return Factory::create( 53 | (string) $data['database']['dsn'], 54 | (string) ($data['database']['username'] ?? ''), 55 | (string) ($data['database']['password'] ?? ''), 56 | (array) ($data['database']['options'] ?? []) 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/CommandLine/PromptTrait.php: -------------------------------------------------------------------------------- 1 | 21 | * @psalm-suppress 22 | */ 23 | public function getScreenSize(): array 24 | { 25 | $output = []; 26 | \preg_match_all( 27 | "/rows.([0-9]+);.columns.([0-9]+);/", 28 | \strtolower(\exec('stty -a | grep columns')), 29 | $output 30 | ); 31 | /** @var array> $output */ 32 | if (\sizeof($output) === 3) { 33 | /** @var array $width */ 34 | $width = $output[2]; 35 | /** @var array $height */ 36 | $height = $output[1]; 37 | return [ 38 | $width[0], 39 | $height[0] 40 | ]; 41 | } 42 | return [80, 25]; 43 | } 44 | 45 | /** 46 | * Prompt the user for an input value 47 | * 48 | * @param string $text 49 | * @return string 50 | */ 51 | final protected function prompt(string $text = ''): string 52 | { 53 | return \readline($text . ' '); 54 | } 55 | 56 | /** 57 | * Interactively prompts for input without echoing to the terminal. 58 | * Requires a bash shell or Windows and won't work with 59 | * safe_mode settings (Uses `shell_exec`) 60 | * 61 | * @ref http://www.sitepoint.com/interactive-cli-password-prompt-in-php/ 62 | * 63 | * @param string $text 64 | * @return string 65 | * @throws \Exception 66 | * @psalm-suppress ForbiddenCode 67 | */ 68 | final protected function silentPrompt(string $text = "Enter Password:"): string 69 | { 70 | if (\preg_match('/^win/i', PHP_OS)) { 71 | $vbscript = sys_get_temp_dir() . 'prompt_password.vbs'; 72 | file_put_contents( 73 | $vbscript, 74 | 'wscript.echo(InputBox("'. \addslashes($text) . '", "", "password here"))' 75 | ); 76 | $command = "cscript //nologo " . \escapeshellarg($vbscript); 77 | 78 | $exec = (string) \shell_exec($command); 79 | $password = \rtrim($exec); 80 | \unlink($vbscript); 81 | return $password; 82 | } else { 83 | /** @var string $command */ 84 | $command = "/usr/bin/env bash -c 'echo OK'"; 85 | /** @var string $exec */ 86 | $exec = (string) \shell_exec($command); 87 | if (\rtrim($exec) !== 'OK') { 88 | throw new \Exception("Can't invoke bash"); 89 | } 90 | $command = "/usr/bin/env bash -c 'read -s -p \"". addslashes($text). "\" mypassword && echo \$mypassword'"; 91 | /** @var string $exec2 */ 92 | $exec2 = (string) \shell_exec($command); 93 | $password = \rtrim($exec2); 94 | echo "\n"; 95 | return $password; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/CommandLine/README.md: -------------------------------------------------------------------------------- 1 | # CommandLine 2 | 3 | This directory contains the CLI interface for Herd. 4 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | > */ 35 | protected $remotes = []; 36 | 37 | /** @var string $tlsCertDir */ 38 | protected $tlsCertDir = ''; 39 | 40 | /** 41 | * Can the core vendor replace keys for other vendors? 42 | * 43 | * @return bool 44 | */ 45 | public function allowCoreToManageKeys(): bool 46 | { 47 | return $this->coreKeyManagement; 48 | } 49 | 50 | /** 51 | * If the core vendor can replace keys for other vendors, is this done 52 | * automatically (i.e. without user interaction)? 53 | * 54 | * If not, the changes are staged for manual inspection. 55 | * 56 | * @return bool 57 | */ 58 | public function allowNonInteractiveKeyManagement(): bool 59 | { 60 | return $this->coreAutoKeyManagement; 61 | } 62 | 63 | /** 64 | * @return string 65 | */ 66 | public function getCoreVendorName(): string 67 | { 68 | return $this->coreVendorName; 69 | } 70 | 71 | /** 72 | * @return bool 73 | */ 74 | public function getMinimalHistory(): bool 75 | { 76 | return $this->minimalHistory; 77 | } 78 | 79 | /** 80 | * @return int 81 | */ 82 | public function getQuorum(): int 83 | { 84 | return $this->quorum; 85 | } 86 | 87 | /** 88 | * @return array> 89 | */ 90 | public function getRemotes(): array 91 | { 92 | return $this->remotes; 93 | } 94 | 95 | /** 96 | * @return string 97 | */ 98 | public function getTlsCertDirectory(): string 99 | { 100 | return $this->tlsCertDir; 101 | } 102 | 103 | /** 104 | * Load configuration from a file. 105 | * 106 | * @param string $path 107 | * @return self 108 | * @throws FilesystemException 109 | * @throws EncodingError 110 | */ 111 | public static function fromFile(string $path = ''): self 112 | { 113 | if (!$path) { 114 | $path = \dirname(__DIR__) . '/data/config.json'; 115 | } 116 | if (!\is_readable($path)) { 117 | throw new FilesystemException('Cannot read configuration file.'); 118 | } 119 | /** @var string $contents */ 120 | $contents = \file_get_contents($path); 121 | if (!\is_string($contents)) { 122 | throw new FilesystemException('Error reading configuration file.'); 123 | } 124 | /** @var array $decode */ 125 | $decode = \json_decode($contents, true); 126 | if (!\is_array($decode)) { 127 | throw new EncodingError('Could not decode JSON string in ' . \realpath($path)); 128 | } 129 | 130 | $config = new static(); 131 | $config->coreVendorName = (string) ($decode['core-vendor'] ?? 'paragonie'); 132 | if (!empty($decode['remotes'])) { 133 | /** @var array> $remotes */ 134 | $remotes = (array) $decode['remotes']; 135 | $config->remotes = $remotes; 136 | } 137 | 138 | /** @var array $policies */ 139 | $policies = $decode['policies'] ?? []; 140 | if (!empty($policies['core-vendor-manage-keys-allow'])) { 141 | $config->coreKeyManagement = true; 142 | $config->coreAutoKeyManagement = !empty($policies['core-vendor-manage-keys-auto']); 143 | } 144 | 145 | if (isset($policies['quorum'])) { 146 | if (\is_int($policies['quorum'])) { 147 | $config->quorum = (int) $policies['quorum']; 148 | } 149 | } 150 | 151 | if (!empty($policies['tls-cert-dir'])) { 152 | $config->tlsCertDir = (string) $policies['tls-cert-dir']; 153 | } else { 154 | $config->tlsCertDir = \dirname(__DIR__) . '/data/certs'; 155 | } 156 | 157 | $config->minimalHistory = !empty($policies['minimal-history']); 158 | 159 | return $config; 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Data/Cacheable.php: -------------------------------------------------------------------------------- 1 | ezdb = $db; 40 | } 41 | 42 | /** 43 | * @return EasyDB 44 | */ 45 | public function getDatabase(): EasyDB 46 | { 47 | return $this->ezdb; 48 | } 49 | 50 | /** 51 | * @param string $path 52 | * @return Config 53 | * @throws EncodingError 54 | * @throws FilesystemException 55 | */ 56 | public function loadConfigFile(string $path = ''): Config 57 | { 58 | if (!$path) { 59 | $path = dirname(dirname(__DIR__)) . '/data/config.json'; 60 | } 61 | return Config::fromFile($path); 62 | } 63 | 64 | 65 | /** 66 | * @param int $id 67 | * @return HistoryRecord 68 | * @throws EmptyValueException 69 | */ 70 | public function loadHistory(int $id): HistoryRecord 71 | { 72 | try { 73 | $cached = ObjectCache::get('history', $id); 74 | if (!($cached instanceof HistoryRecord)) { 75 | throw new EmptyValueException('Cached history not instance of HistoryRecord'); 76 | } 77 | return $cached; 78 | } catch (EmptyValueException $ex) { 79 | } 80 | /** @var array $hist */ 81 | $hist = $this->ezdb->row("SELECT * FROM herd_history WHERE id = ?", $id); 82 | if (empty($hist)) { 83 | throw new EmptyValueException('History #' . $id . ' not found'); 84 | } 85 | $history = new HistoryRecord( 86 | new SigningPublicKey( 87 | Base64UrlSafe::decode($hist['publickey']) 88 | ), 89 | $hist['signature'], 90 | $hist['contents'], 91 | $hist['hash'], 92 | $hist['summaryhash'], 93 | $hist['prevhash'], 94 | !empty($hist['accepted']) 95 | ); 96 | ObjectCache::set($history, 'history', $id); 97 | return $history; 98 | } 99 | 100 | /** 101 | * @param int $id 102 | * @return Product 103 | * @throws EmptyValueException 104 | */ 105 | public function loadProduct(int $id): Product 106 | { 107 | try { 108 | $cached = ObjectCache::get('product', $id); 109 | if (!($cached instanceof Product)) { 110 | throw new EmptyValueException('Cached product not instance of Product'); 111 | } 112 | return $cached; 113 | } catch (EmptyValueException $ex) { 114 | } 115 | /** @var array $prod */ 116 | $prod = $this->ezdb->row("SELECT * FROM herd_products WHERE id = ?", $id); 117 | if (empty($prod)) { 118 | throw new EmptyValueException('Product #' . $id . ' not found'); 119 | } 120 | 121 | $vendor = (int) $prod['vendor']; 122 | $product = new Product( 123 | $this->loadVendor($vendor), 124 | (string) $prod['name'] 125 | ); 126 | ObjectCache::set($product, 'product', $id); 127 | return $product; 128 | } 129 | 130 | /** 131 | * @param int $id 132 | * @return Release 133 | * @throws EmptyValueException 134 | */ 135 | public function loadProductRelease(int $id): Release 136 | { 137 | try { 138 | $cached = ObjectCache::get('release', $id); 139 | if (!($cached instanceof Release)) { 140 | throw new EmptyValueException('Cached release not instance of Release'); 141 | } 142 | return $cached; 143 | } catch (EmptyValueException $ex) { 144 | } 145 | /** @var array $r */ 146 | $r = $this->ezdb->row("SELECT * FROM herd_product_updates WHERE id = ?", $id); 147 | if (empty($r)) { 148 | throw new EmptyValueException('Product Release #' . $id . ' not found'); 149 | } 150 | $release = new Release( 151 | $this->loadProduct((int) $r['product']), 152 | (string) $r['version'], 153 | (string) $r['body'], 154 | (string) Base64UrlSafe::decode((string) $r['signature']), 155 | (string) $r['summaryhash'] 156 | ); 157 | ObjectCache::set($release, 'release', $id); 158 | return $release; 159 | } 160 | 161 | /** 162 | * @param int $id 163 | * @return Vendor 164 | * @throws EmptyValueException 165 | */ 166 | public function loadVendor(int $id): Vendor 167 | { 168 | try { 169 | $cached = ObjectCache::get('vendor', $id); 170 | if (!($cached instanceof Vendor)) { 171 | throw new EmptyValueException('Cached vendor not instance of Vendor'); 172 | } 173 | return $cached; 174 | } catch (EmptyValueException $ex) { 175 | } 176 | 177 | /** @var string $name */ 178 | $name = (string) $this->ezdb->cell("SELECT name FROM herd_vendors WHERE id = ?", $id); 179 | if (empty($name)) { 180 | throw new EmptyValueException('Vendor #' . $id . ' not found'); 181 | } 182 | $vendor = new Vendor($name); 183 | /** @var array> $vendorKeys */ 184 | $vendorKeys = $this->ezdb->run("SELECT publickey FROM herd_vendor_keys WHERE trusted"); 185 | foreach ($vendorKeys as $vk) { 186 | /** @var array $vk */ 187 | $vendor->appendPublicKey( 188 | new SigningPublicKey( 189 | Base64UrlSafe::decode((string) $vk['publickey']) 190 | ) 191 | ); 192 | } 193 | ObjectCache::set($vendor, 'vendor', $id); 194 | return $vendor; 195 | } 196 | 197 | /** 198 | * @param \PDO $obj 199 | * @return self 200 | */ 201 | public static function fromPDO(\PDO $obj): self 202 | { 203 | return new self(new EasyDB($obj)); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Data/ObjectCache.php: -------------------------------------------------------------------------------- 1 | > */ 17 | public static $cache = []; 18 | 19 | /** @var string $cacheKey */ 20 | public static $cacheKey = ''; 21 | 22 | /** 23 | * Get the cache key for this type. 24 | * 25 | * @param string $type 26 | * @return string 27 | * 28 | * @throws \SodiumException 29 | */ 30 | public static function getCacheKey(string $type): string 31 | { 32 | if (empty(static::$cacheKey)) { 33 | static::$cacheKey = \random_bytes(\SODIUM_CRYPTO_SHORTHASH_KEYBYTES); 34 | } 35 | return \sodium_bin2hex( 36 | \sodium_crypto_shorthash( 37 | $type, 38 | static::$cacheKey 39 | ) 40 | ); 41 | } 42 | 43 | /** 44 | * @param string $type 45 | * @param int $id 46 | * @return Cacheable 47 | * 48 | * @throws EmptyValueException 49 | * @throws \SodiumException 50 | */ 51 | public static function get(string $type, int $id = 0): Cacheable 52 | { 53 | $key = static::getCacheKey($type); 54 | if (empty(static::$cache[$key])) { 55 | throw new EmptyValueException('No data cached of this type.'); 56 | } 57 | if (empty(static::$cache[$key][$id])) { 58 | throw new EmptyValueException('Cache miss.'); 59 | } 60 | /** @var Cacheable $c */ 61 | $c = static::$cache[$key][$id]; 62 | return $c; 63 | } 64 | 65 | /** 66 | * @param string $type 67 | * @param int $id 68 | * @return bool 69 | * 70 | * @throws \SodiumException 71 | */ 72 | public static function remove(string $type, int $id): bool 73 | { 74 | $key = static::getCacheKey($type); 75 | if (empty(static::$cache[$key])) { 76 | return false; 77 | } 78 | if (empty(static::$cache[$key][$id])) { 79 | return false; 80 | } 81 | unset(static::$cache[$key][$id]); 82 | return true; 83 | } 84 | 85 | /** 86 | * @param Cacheable $obj 87 | * @param string $type 88 | * @param int $id 89 | * @return Cacheable 90 | * 91 | * @throws \SodiumException 92 | */ 93 | public static function set(Cacheable $obj, string $type, int $id): Cacheable 94 | { 95 | $key = static::getCacheKey($type); 96 | if (empty(static::$cache[$key])) { 97 | static::$cache[$key] = []; 98 | } 99 | static::$cache[$key][$id] = $obj; 100 | return $obj; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Data/Remote.php: -------------------------------------------------------------------------------- 1 | url = $url; 51 | $this->publicKey = $publicKey; 52 | $this->isPrimary = $isPrimary; 53 | $this->certPath = $certPath; 54 | } 55 | 56 | /** 57 | * @return SigningPublicKey 58 | */ 59 | public function getPublicKey(): SigningPublicKey 60 | { 61 | return $this->publicKey; 62 | } 63 | 64 | /** 65 | * @return string 66 | */ 67 | public function getUrl(): string 68 | { 69 | return $this->url; 70 | } 71 | 72 | /** 73 | * @return bool 74 | */ 75 | public function isPrimary(): bool 76 | { 77 | return $this->isPrimary; 78 | } 79 | 80 | /** 81 | * Lookup a hash in a Remote source. Return the PSR-7 response object 82 | * containing the Chronicle's API response and headers. 83 | * 84 | * @param string $summaryHash 85 | * @return ResponseInterface 86 | * @throws BundleException 87 | * @throws CertaintyException 88 | * @throws \SodiumException 89 | * @throws \TypeError 90 | */ 91 | public function lookup(string $summaryHash): ResponseInterface 92 | { 93 | /** @var Client $http */ 94 | $http = new Client(); 95 | 96 | /** @var Response $response */ 97 | $response = $http->get( 98 | $this->url . '/lookup/' . \urlencode($summaryHash), 99 | // We're going to use Certainty to always fetch the latest CACert bundle 100 | [ 101 | 'verify' => (new RemoteFetch($this->certPath)) 102 | ->getLatestBundle() 103 | ->getFilePath() 104 | ] 105 | ); 106 | if (!($response instanceof ResponseInterface)) { 107 | throw new \TypeError('Did not get a PSR-7 Response object'); 108 | } 109 | return $response; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Exception/ChronicleException.php: -------------------------------------------------------------------------------- 1 | $remotes */ 43 | protected $remotes = []; 44 | 45 | /** @var Sapient $sapient */ 46 | protected $sapient; 47 | 48 | /** 49 | * Herd constructor. 50 | * 51 | * @param Local $local 52 | * @param Config|null $config 53 | * @param Sapient|null $sapient 54 | * 55 | * @throws EncodingError 56 | * @throws FilesystemException 57 | */ 58 | public function __construct( 59 | Local $local, 60 | Config $config = null, 61 | Sapient $sapient = null 62 | ) { 63 | $this->local = $local; 64 | if (!$config) { 65 | $config = $this->local->loadConfigFile(); 66 | } 67 | $this->config = $config; 68 | foreach ($config->getRemotes() as $rem) { 69 | if (!\is_string($rem['public-key']) || !\is_string($rem['url'])) { 70 | continue; 71 | } 72 | $this->addRemote( 73 | new Remote( 74 | $rem['url'], 75 | new SigningPublicKey( 76 | Base64UrlSafe::decode($rem['public-key']) 77 | ), 78 | !empty($rem['primary']), 79 | $config->getTlsCertDirectory() 80 | ) 81 | ); 82 | } 83 | if (!$sapient) { 84 | $sapient = new Sapient(new Guzzle(new Client())); 85 | } 86 | $this->sapient = $sapient; 87 | } 88 | 89 | /** 90 | * @param Remote $remote 91 | * @return self 92 | */ 93 | public function addRemote(Remote $remote): self 94 | { 95 | $this->remotes[] = $remote; 96 | return $this; 97 | } 98 | 99 | /** 100 | * @return array 101 | */ 102 | public function getAllRemotes(): array 103 | { 104 | return $this->remotes; 105 | } 106 | 107 | /** 108 | * @return Config 109 | */ 110 | public function getConfig(): Config 111 | { 112 | return $this->config; 113 | } 114 | 115 | /** 116 | * @return EasyDB 117 | */ 118 | public function getDatabase(): EasyDB 119 | { 120 | return $this->local->getDatabase(); 121 | } 122 | 123 | /** 124 | * @return string 125 | */ 126 | public function getLatestSummaryHash(): string 127 | { 128 | /** @var string $summaryHash */ 129 | $summaryHash = (string) $this->local->getDatabase()->cell( 130 | "SELECT summaryhash FROM herd_history ORDER BY id DESC LIMIT 1" 131 | ); 132 | if (!empty($summaryHash)) { 133 | return (string) $summaryHash; 134 | } 135 | return ''; 136 | } 137 | 138 | /** 139 | * Get an array of updates from a given Remote source. 140 | * 141 | * @param string $summaryHash 142 | * @param Remote|null $target 143 | * @return array> 144 | * 145 | * @throws CertaintyException 146 | * @throws ChronicleException 147 | * @throws EmptyValueException 148 | * @throws InvalidMessageException 149 | * @throws \SodiumException 150 | */ 151 | public function getUpdatesSince(string $summaryHash = '', Remote $target = null): array 152 | { 153 | if (empty($summaryHash)) { 154 | $summaryHash = $this->getLatestSummaryHash(); 155 | } 156 | if (!$target) { 157 | try { 158 | $target = $this->selectRemote(true); 159 | } catch (EmptyValueException $ex) { 160 | throw $ex; 161 | } catch (\Throwable $ex) { 162 | return []; 163 | } 164 | } 165 | 166 | if (!empty($summaryHash)) { 167 | // Only get the new entries 168 | $url = $target->getUrl() . '/since/' . \urlencode($summaryHash); 169 | } else { 170 | // First run: Grab everything 171 | $url = $target->getUrl() . '/export'; 172 | } 173 | 174 | /** @var Client $http */ 175 | $http = new Client(); 176 | 177 | /** @var Response $response */ 178 | $response = $http->get( 179 | $url, 180 | // We're going to use Certainty to always fetch the latest CACert bundle 181 | [ 182 | 'verify' => (new RemoteFetch($this->config->getTlsCertDirectory())) 183 | ->getLatestBundle() 184 | ->getFilePath() 185 | ] 186 | ); 187 | 188 | /** @var array>> $decoded */ 189 | $decoded = $this->sapient->decodeSignedJsonResponse( 190 | $response, 191 | $target->getPublicKey() 192 | ); 193 | 194 | // If the status was anything except "OK", raise the alarm: 195 | if ($decoded['status'] !== 'OK') { 196 | if (\is_string($decoded['message'])) { 197 | throw new ChronicleException($decoded['message']); 198 | } 199 | throw new ChronicleException('An unknown error has occurred with the Chronicle'); 200 | } 201 | if (\is_array($decoded['results'])) { 202 | /** @var array> $results */ 203 | $results = $decoded['results']; 204 | return $results; 205 | } 206 | return []; 207 | } 208 | 209 | /** 210 | * Select a random Remote. If you pass TRUE to the first argument, it will 211 | * select a non-primary Remote source. Otherwise, it just grabs one at 212 | * complete random. If no non-primary Remote sources are available, you will 213 | * end up getting the primary one anyway. 214 | * 215 | * The purpose of selecting non-primary Remotes was to prevent overloading 216 | * the central repository and instead querying a replication instance of the 217 | * upstream Chronicle. 218 | * 219 | * @param bool $notPrimary 220 | * @return Remote 221 | * @throws EmptyValueException 222 | * @throws \Exception from random_int() 223 | */ 224 | public function selectRemote(bool $notPrimary = false): Remote 225 | { 226 | if (empty($this->remotes)) { 227 | throw new EmptyValueException('No remote sources are configured'); 228 | } 229 | $count = \count($this->remotes); 230 | $select = \random_int(0, $count - 1); 231 | 232 | if ($notPrimary) { 233 | // We don't want primary remotes if we can help it 234 | $secondary = []; 235 | for ($i = 0; $i < $count; ++$i) { 236 | if (!$this->remotes[$i]->isPrimary()) { 237 | $secondary[] = $i; 238 | } 239 | } 240 | if (!empty($secondary)) { 241 | // We can return a secondary one! 242 | // Otherwise, we'll have to return a primary. 243 | $subcount = \count($secondary); 244 | $select = $secondary[\random_int(0, $subcount - 1)]; 245 | } 246 | } 247 | return $this->remotes[$select]; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/History.php: -------------------------------------------------------------------------------- 1 | herd = $herd; 41 | } 42 | 43 | /** 44 | * @return EasyDB 45 | */ 46 | public function getDatabase(): EasyDB 47 | { 48 | return $this->herd->getDatabase(); 49 | } 50 | 51 | /** 52 | * Copy from the remote Chronicles onto the local history. 53 | * 54 | * @param bool $useTransaction 55 | * @return bool 56 | * 57 | * @throws CertaintyException 58 | * @throws ChronicleException 59 | * @throws EmptyValueException 60 | * @throws InvalidMessageException 61 | * @throws \SodiumException 62 | */ 63 | public function transcribe(bool $useTransaction = true): bool 64 | { 65 | $remote = $this->herd->selectRemote(true); 66 | /** @var array> $updates */ 67 | $updates = $this->herd->getUpdatesSince('', $remote); 68 | if (!$updates) { 69 | return true; 70 | } 71 | $db = $this->herd->getDatabase(); 72 | 73 | // Don't wrap this in a transaction unless told to: 74 | if ($useTransaction) { 75 | $db->beginTransaction(); 76 | } 77 | $inserts = 0; 78 | /** @var array $last */ 79 | $last = $db->row("SELECT * FROM herd_history ORDER BY id DESC LIMIT 1"); 80 | if (empty($last)) { 81 | $last = []; 82 | } 83 | foreach ($updates as $up) { 84 | /** @var array $up */ 85 | if (!isset( 86 | $up['contents'], 87 | $up['prev'], 88 | $up['hash'], 89 | $up['summary'], 90 | $up['created'], 91 | $up['publickey'], 92 | $up['signature'] 93 | )) { 94 | continue; 95 | } 96 | if ($this->isValidNextEntry($up, $last)) { 97 | if (!$this->quorumAgrees($remote, $up['summary'], $up['hash'])) { 98 | /* The quorum did not agree with this entry. */ 99 | continue; 100 | } 101 | $db->insert( 102 | 'herd_history', 103 | [ 104 | 'contents' => $up['contents'], 105 | 'prevhash' => $up['prev'], 106 | 'hash' => $up['hash'], 107 | 'summaryhash' => $up['summary'], 108 | 'publickey' => $up['publickey'], 109 | 'created' => $up['created'], 110 | 'signature' => $up['signature'] 111 | ] 112 | ); 113 | /** @var int $historyID */ 114 | $historyID = $db->cell( 115 | "SELECT id FROM herd_history WHERE summaryhash = ?", 116 | $up['summary'] 117 | ); 118 | try { 119 | $this->parseContentsAndInsert( 120 | $up['contents'], 121 | (int) $historyID, 122 | $up['summary'] 123 | ); 124 | } catch (\Throwable $ex) { 125 | $this->markAccepted((int) $historyID); 126 | } 127 | ++$inserts; 128 | /** @var array $last */ 129 | $last = $up; 130 | } 131 | } 132 | if ($this->herd->getConfig()->getMinimalHistory()) { 133 | $this->pruneHistory(); 134 | } 135 | if ($inserts === 0) { 136 | // This should not be rolled back unless told to: 137 | if ($useTransaction) { 138 | $db->rollBack(); 139 | } 140 | return false; 141 | } 142 | // This should not be committed unless $useTransaction is TRUE: 143 | if ($useTransaction) { 144 | return $db->commit(); 145 | } 146 | return true; 147 | } 148 | 149 | /** 150 | * @param array $up 151 | * @param array $prev 152 | * @return bool 153 | * 154 | * @throws \SodiumException 155 | */ 156 | public function isValidNextEntry(array $up, array $prev = []): bool 157 | { 158 | if (!empty($prev)) { 159 | if (!\hash_equals($up['prev'], $prev['hash'])) { 160 | // This does not follow from the previous hash. 161 | return false; 162 | } 163 | } 164 | 165 | /** @var string $publicKey */ 166 | $publicKey = (string) Base64UrlSafe::decode($up['publickey']); 167 | /** @var string $signature */ 168 | $signature = (string) Base64UrlSafe::decode($up['signature']); 169 | 170 | if (!\sodium_crypto_sign_verify_detached($signature, $up['contents'], $publicKey)) { 171 | // Invalid signature! 172 | return false; 173 | } 174 | 175 | /** @var string $prevHash */ 176 | if (empty($up['prev'])) { 177 | $prevHash = ''; 178 | } else { 179 | $prevHash = (string) Base64UrlSafe::decode($up['prev']); 180 | } 181 | /** @var string $hash */ 182 | $hash = (string) Base64UrlSafe::decode($up['hash']); 183 | 184 | /** @var string $calcHash */ 185 | $calcHash = \sodium_crypto_generichash( 186 | $up['created'] . $publicKey . $signature . $up['contents'], 187 | $prevHash 188 | ); 189 | 190 | if (!\hash_equals($calcHash, $hash)) { 191 | // Hash did not match 192 | return false; 193 | } 194 | 195 | // All checks pass! 196 | return true; 197 | } 198 | 199 | /** 200 | * @param string $contents 201 | * @param int $historyID 202 | * @param string $hash 203 | * @param bool $override 204 | * 205 | * @return void 206 | * @throws EmptyValueException 207 | * @throws InvalidOperationException 208 | * @throws \Exception 209 | */ 210 | public function parseContentsAndInsert( 211 | string $contents, 212 | int $historyID, 213 | string $hash, 214 | bool $override = false 215 | ) { 216 | /** @var array $decoded */ 217 | $decoded = \json_decode($contents, true); 218 | if (!\is_array($decoded)) { 219 | // Not a JSON message. 220 | $this->markAccepted($historyID); 221 | return; 222 | } 223 | if (!isset( 224 | $decoded['op'], 225 | $decoded['op-body'], 226 | $decoded['op-sig'], 227 | $decoded['op-public-key'] 228 | )) { 229 | // Not a JSON message for our usage 230 | $this->markAccepted($historyID); 231 | return; 232 | } 233 | switch ($decoded['op']) { 234 | case 'add-key': 235 | $this->addPublicKey($decoded, $historyID, $hash, $override); 236 | break; 237 | case 'revoke-key': 238 | $this->revokePublicKey($decoded, $historyID, $hash, $override); 239 | break; 240 | case 'update': 241 | $this->registerUpdate($decoded, $historyID, $hash); 242 | break; 243 | default: 244 | // Unknown or unsupported operation. 245 | $this->markAccepted($historyID); 246 | return; 247 | } 248 | } 249 | 250 | /** 251 | * Irrelevant junk just gets marked as "accepted" so we don't prompt later 252 | * 253 | * @param int $historyID 254 | * @return void 255 | */ 256 | protected function markAccepted(int $historyID) 257 | { 258 | $this->herd->getDatabase()->update( 259 | 'herd_history', 260 | ['accepted' => true], 261 | ['id' => $historyID] 262 | ); 263 | } 264 | 265 | /** 266 | * This creates a new public key for a vendor. If the vendor does not 267 | * exist, they will be created. 268 | * 269 | * @param array $data 270 | * @param int $historyID 271 | * @param string $hash 272 | * @param bool $override 273 | * @return void 274 | * @throws EmptyValueException 275 | * @throws \Exception 276 | */ 277 | protected function addPublicKey( 278 | array $data, 279 | int $historyID, 280 | string $hash, 281 | bool $override = false 282 | ) { 283 | try { 284 | $this->validateMessage($data, 'add-key'); 285 | } catch (ChronicleException $ex) { 286 | return; 287 | } 288 | 289 | /** @var array $opBody */ 290 | $opBody = \json_decode($data['op-body'], true); 291 | if (!\is_array($opBody)) { 292 | return; 293 | } 294 | $db = $this->herd->getDatabase(); 295 | 296 | try { 297 | /** @var int $vendorID */ 298 | $vendorID = Vendor::getVendorID($db, $opBody['vendor']); 299 | } catch (EmptyValueException $ex) { 300 | // Creating a new vendor! 301 | /** @var int $vendorID */ 302 | $vendorID = (int) $db->insertGet( 303 | 'herd_vendors', 304 | [ 305 | 'name' => $opBody['vendor'], 306 | 'created' => (new \DateTime())->format(\DateTime::ATOM), 307 | 'modified' => (new \DateTime())->format(\DateTime::ATOM) 308 | ], 309 | 'id' 310 | ); 311 | } 312 | $proceed = false; 313 | try { 314 | Vendor::keySearch($db, $vendorID, $data['op-public-key']); 315 | $proceed = true; 316 | } catch (EmptyValueException $ex) { 317 | $config = $this->herd->getConfig(); 318 | if ($config->allowCoreToManageKeys()) { 319 | $coreVendorID = Vendor::getVendorID($db, $config->getCoreVendorName()); 320 | 321 | // We don't catch this one: 322 | Vendor::keySearch($db, $coreVendorID, $data['op-public-key']); 323 | 324 | // Only proceed if we're allowed 325 | $proceed = $config->allowNonInteractiveKeyManagement(); 326 | } 327 | } 328 | 329 | if (!empty($proceed) || $override) { 330 | // Insert the vendor key: 331 | $db->insert( 332 | 'herd_vendor_keys', 333 | [ 334 | 'history_create' => $historyID, 335 | 'summaryhash_create' => $hash, 336 | 'trusted' => true, 337 | 'vendor' => $vendorID, 338 | 'name' => $opBody['name'] 339 | ?? 340 | $db->cell( 341 | 'SELECT summaryhash FROM herd_history WHERE id = ?', 342 | $historyID 343 | ), 344 | 'publickey' => $opBody['publickey'], 345 | 'created' => (new \DateTime())->format(\DateTime::ATOM), 346 | 'modified' => (new \DateTime())->format(\DateTime::ATOM) 347 | ] 348 | ); 349 | $this->markAccepted($historyID); 350 | } 351 | } 352 | 353 | /** 354 | * This revokes an existing public key for a given vendor. 355 | * 356 | * @param array $data 357 | * @param int $historyID 358 | * @param string $hash 359 | * @param bool $override 360 | * @return void 361 | * 362 | * @throws EmptyValueException 363 | * @throws InvalidOperationException 364 | * @throws \SodiumException 365 | */ 366 | protected function revokePublicKey( 367 | array $data, 368 | int $historyID, 369 | string $hash, 370 | bool $override = false 371 | ) { 372 | try { 373 | $this->validateMessage($data, 'revoke-key'); 374 | } catch (ChronicleException $ex) { 375 | return; 376 | } 377 | 378 | /** @var array $opBody */ 379 | $opBody = \json_decode($data['op-body'], true); 380 | if (!\is_array($opBody)) { 381 | return; 382 | } 383 | $config = $this->herd->getConfig(); 384 | if (\hash_equals($data['op-public-key'], $opBody['publickey'])) { 385 | throw new InvalidOperationException( 386 | 'You cannot revoke your own key. You must provide a new one first.' 387 | ); 388 | } 389 | 390 | $db = $this->herd->getDatabase(); 391 | 392 | $vendorID = Vendor::getVendorID($db, $opBody['vendor']); 393 | // Do not catch: 394 | $targetKeyID = Vendor::keySearch($db, $vendorID, $opBody['publickey']); 395 | 396 | $proceed = false; 397 | try { 398 | Vendor::keySearch($db, $vendorID, $data['op-public-key']); 399 | $proceed = true; 400 | } catch (EmptyValueException $ex) { 401 | if ($config->allowCoreToManageKeys()) { 402 | $coreVendorID = Vendor::getVendorID($db, $config->getCoreVendorName()); 403 | 404 | // We don't catch this one: 405 | Vendor::keySearch($db, $coreVendorID, $data['op-public-key']); 406 | 407 | // Only proceed if we're allowed 408 | $proceed = $config->allowNonInteractiveKeyManagement(); 409 | } 410 | } 411 | if (!empty($proceed) || $override) { 412 | // Revoke the vendor key: 413 | $db->update( 414 | 'herd_vendor_keys', 415 | [ 416 | 'history_revoke' => $historyID, 417 | 'summaryhash_revoke' => $hash, 418 | 'trusted' => false, 419 | 'modified' => (new \DateTime())->format(\DateTime::ATOM) 420 | ], 421 | [ 422 | 'id' => $targetKeyID 423 | ] 424 | ); 425 | $this->markAccepted($historyID); 426 | } 427 | } 428 | 429 | /** 430 | * This registers metadata about a new software update into the local 431 | * database. 432 | * 433 | * @param array $data 434 | * @param int $historyID 435 | * @param string $hash 436 | * @return void 437 | * @throws EmptyValueException 438 | * @throws \Exception 439 | */ 440 | protected function registerUpdate(array $data, int $historyID, string $hash) 441 | { 442 | try { 443 | $this->validateMessage($data, 'update'); 444 | } catch (ChronicleException $ex) { 445 | return; 446 | } 447 | 448 | /** @var array $opBody */ 449 | $opBody = \json_decode($data['op-body'], true); 450 | if (!\is_array($opBody)) { 451 | return; 452 | } 453 | if (!isset( 454 | $opBody['vendor'], 455 | $opBody['name'], 456 | $opBody['version'], 457 | $opBody['metadata'] 458 | )) { 459 | throw new EmptyValueException('Incomplete data packet.'); 460 | } 461 | 462 | $db = $this->herd->getDatabase(); 463 | 464 | $vendorID = Vendor::getVendorID($db, $opBody['vendor']); 465 | $publicKeyID = Vendor::keySearch($db, $vendorID, $data['op-public-key']); 466 | $productID = Product::upsert($db, $vendorID, $opBody['name']); 467 | 468 | $db->insert( 469 | 'herd_product_updates', 470 | [ 471 | 'history' => $historyID, 472 | 'summaryhash' => $hash, 473 | 'version' => $opBody['version'], 474 | 'body' => $data['op-body'], 475 | 'product' => $productID, 476 | 'publickey' => $publicKeyID, 477 | 'signature' => $data['op-sig'], 478 | 'created' => (new \DateTime()) 479 | ->format(\DateTime::ATOM), 480 | 'modified' => (new \DateTime()) 481 | ->format(\DateTime::ATOM) 482 | ] 483 | ); 484 | $this->markAccepted($historyID); 485 | } 486 | 487 | /** 488 | * Data/signature validation for an incoming message. 489 | * 490 | * @param array $data 491 | * @param string $operation 492 | * @return void 493 | * 494 | * @throws ChronicleException 495 | * @throws \SodiumException 496 | */ 497 | protected function validateMessage(array $data, string $operation) 498 | { 499 | if (!isset( 500 | $data['op'], 501 | $data['op-body'], 502 | $data['op-sig'], 503 | $data['op-public-key'] 504 | )) { 505 | throw new ChronicleException('Not a JSON message for our usage'); 506 | } 507 | if (!\hash_equals($operation, $data['op'])) { 508 | throw new ChronicleException('Invalid operation'); 509 | } 510 | 511 | /** @var string $publicKey */ 512 | $publicKey = (string) Base64UrlSafe::decode($data['op-public-key']); 513 | /** @var string $signature */ 514 | $signature = (string) Base64UrlSafe::decode($data['op-sig']); 515 | if (!\sodium_crypto_sign_verify_detached( 516 | $signature, 517 | $data['op-body'], 518 | $publicKey 519 | )) { 520 | throw new ChronicleException('Invalid signature'); 521 | } 522 | } 523 | 524 | /** 525 | * Do the other Remotes allow us to establish a quorum (number of alternative 526 | * Remotes that must agree on the existence of this summary hash)? 527 | * 528 | * @param Remote $used 529 | * @param string $summary 530 | * @param string $currHash 531 | * @return bool 532 | * @throws \RangeException 533 | */ 534 | protected function quorumAgrees( 535 | Remote $used, 536 | string $summary, 537 | string $currHash 538 | ): bool { 539 | $config = $this->herd->getConfig(); 540 | $quorum = $config->getQuorum(); 541 | if (empty($quorum)) { 542 | return true; 543 | } 544 | $sapient = new Sapient(); 545 | 546 | /** @var array $remotes */ 547 | $remotes = $this->herd->getAllRemotes(); 548 | 549 | // Remove duplicates 550 | foreach ($remotes as $i => $r) { 551 | /** @var Remote $r */ 552 | if ($used->getUrl() === $r->getUrl()) { 553 | unset($remotes[$i]); 554 | } 555 | } 556 | 557 | if ($quorum > \count($remotes)) { 558 | // Prevent always-returning-false conditions: 559 | throw new \RangeException( 560 | 'Quorum threshold is larger than available Remote pool' 561 | ); 562 | } 563 | 564 | // As long as we have remotes left to query and have yet to establish quorum, 565 | // keep querying other remote Chronicles. 566 | $r = 0; 567 | while ($quorum > 0 && !empty($remotes)) { 568 | // Select one at random: 569 | try { 570 | $r = \random_int(1, \count($remotes)) - 1; 571 | /** @var Remote $remote */ 572 | $remote = $remotes[$r]; 573 | $decoded = $sapient->decodeSignedJsonResponse( 574 | $remote->lookup($summary), 575 | $remote->getPublicKey() 576 | ); 577 | if ($decoded['status'] === 'OK') { 578 | // Response was OK. Now let's search the responses for our currhash. 579 | $match = false; 580 | /** @var array $res */ 581 | foreach ($decoded['results'] as $res) { 582 | if (\hash_equals($currHash, $res['currhash'])) { 583 | $match = true; 584 | break; 585 | } 586 | } 587 | if ($match) { 588 | // This Remote source sees the same summary hash. 589 | --$quorum; 590 | } 591 | } 592 | } catch (\Throwable $ex) { 593 | // Probably a transfer exception. Move on. 594 | } 595 | unset($remotes[$r]); 596 | // Reset keys: 597 | $remotes = \array_values($remotes); 598 | } 599 | 600 | // If we have met quorum, return TRUE. 601 | // If we have yet to meet quorum, return FALSE. 602 | return $quorum < 1; 603 | } 604 | 605 | /** 606 | * Delete everything non-essential from the local database. 607 | * 608 | * This leaves only: 609 | * 610 | * - Non-accepted history entries 611 | * - The most recent entry 612 | * 613 | * @return void 614 | */ 615 | protected function pruneHistory() 616 | { 617 | $db = $this->herd->getDatabase(); 618 | /** @var string $historyID */ 619 | $historyID = $db->cell("SELECT MAX(id) FROM herd_history"); 620 | if (empty($historyID)) { 621 | return; 622 | } 623 | if ($db->getDriver() === 'sqlite') { 624 | $db->query("DELETE FROM herd_history WHERE accepted = 0 AND id < ?", $historyID); 625 | } else { 626 | $db->query("DELETE FROM herd_history WHERE NOT accepted AND id < ?", $historyID); 627 | } 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /src/Model/HistoryRecord.php: -------------------------------------------------------------------------------- 1 | publicKey = $publicKey; 59 | $this->signature = $signature; 60 | $this->contents = $contents; 61 | $this->hash = $hash; 62 | $this->summaryHash = $summaryHash; 63 | $this->previousHash = $prevHash; 64 | $this->accepted = $accepted; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Model/Product.php: -------------------------------------------------------------------------------- 1 | vendor = $vendor; 30 | $this->name = $name; 31 | } 32 | 33 | /** 34 | * @param EasyDB $db 35 | * @param int $id 36 | * @return self 37 | * @throws EmptyValueException 38 | */ 39 | public static function byId(EasyDB $db, int $id): self 40 | { 41 | /** @var array $r */ 42 | $r = $db->row('SELECT * FROM herd_products WHERE id = ?', $id); 43 | if (empty($r)) { 44 | throw new EmptyValueException('Could not find this product'); 45 | } 46 | return new static( 47 | Vendor::byId($db, (int) $r['vendor']), 48 | $r['name'] 49 | ); 50 | } 51 | 52 | /** 53 | * @return string 54 | */ 55 | public function getName(): string 56 | { 57 | return $this->name; 58 | } 59 | 60 | /** 61 | * @return Vendor 62 | */ 63 | public function getVendor(): Vendor 64 | { 65 | return $this->vendor; 66 | } 67 | 68 | /** 69 | * Create the product for this vendor if it does not already exist. 70 | * Return the product ID either way. 71 | * 72 | * @param EasyDB $db 73 | * @param int $vendor 74 | * @param string $name 75 | * @return int 76 | * @throws \Exception 77 | */ 78 | public static function upsert(EasyDB $db, int $vendor, string $name): int 79 | { 80 | /** @var int $exists */ 81 | $exists = $db->cell( 82 | 'SELECT id FROM herd_products WHERE vendor = ? AND name = ?', 83 | $vendor, 84 | $name 85 | ); 86 | if ($exists) { 87 | return (int) $exists; 88 | } 89 | return (int) $db->insertGet( 90 | 'herd_products', 91 | [ 92 | 'vendor' => $vendor, 93 | 'name' => $name, 94 | 'created' => (new \DateTime()) 95 | ->format(\DateTime::ATOM), 96 | 'modified' => (new \DateTime()) 97 | ->format(\DateTime::ATOM) 98 | ], 99 | 'id' 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Model/Release.php: -------------------------------------------------------------------------------- 1 | product = $product; 51 | $this->version = $version; 52 | $this->data = $data; 53 | $this->signature = $signature; 54 | $this->summaryHash = $summaryHash; 55 | } 56 | 57 | /** 58 | * @param EasyDB $db 59 | * @param int $id 60 | * @return self 61 | * @throws EmptyValueException 62 | */ 63 | public static function byId(EasyDB $db, int $id): self 64 | { 65 | /** @var array $r */ 66 | $r = $db->row('SELECT * FROM herd_product_updates WHERE id = ?', $id); 67 | if (empty($r)) { 68 | throw new EmptyValueException('Could not find this software release'); 69 | } 70 | return new static( 71 | Product::byId($db, (int) $r['product']), 72 | $r['version'], 73 | $r['data'], 74 | $r['signature'], 75 | $r['summaryhash'] 76 | ); 77 | } 78 | 79 | /** 80 | * @return bool 81 | */ 82 | public function signatureValid(): bool 83 | { 84 | /** @var array $publicKeys */ 85 | $publicKeys = $this->getPublicKeys(); 86 | foreach ($publicKeys as $pKey) { 87 | /** @var SigningPublicKey $pKey */ 88 | if (\sodium_crypto_sign_verify_detached( 89 | $this->signature, 90 | $this->data, 91 | $pKey->getString(true) 92 | )) { 93 | return true; 94 | } 95 | } 96 | return false; 97 | } 98 | 99 | /** 100 | * @return Product 101 | */ 102 | public function getProduct(): Product 103 | { 104 | return $this->product; 105 | } 106 | 107 | /** 108 | * @return array 109 | */ 110 | public function getPublicKeys(): array 111 | { 112 | return $this 113 | ->product 114 | ->getVendor() 115 | ->getPublicKeys(); 116 | } 117 | 118 | /** 119 | * @return string 120 | */ 121 | public function getSummaryHash(): string 122 | { 123 | return $this->summaryHash; 124 | } 125 | 126 | /** 127 | * @return Vendor 128 | */ 129 | public function getVendor(): Vendor 130 | { 131 | return $this 132 | ->product 133 | ->getVendor(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Model/Vendor.php: -------------------------------------------------------------------------------- 1 | $publicKeys */ 20 | protected $publicKeys = []; 21 | 22 | /** 23 | * Vendor constructor. 24 | * @param string $name 25 | */ 26 | public function __construct(string $name) 27 | { 28 | $this->name = $name; 29 | } 30 | 31 | /** 32 | * @param EasyDB $db 33 | * @param int $id 34 | * @return self 35 | * @throws EmptyValueException 36 | */ 37 | public static function byId(EasyDB $db, int $id): self 38 | { 39 | /** @var array $r */ 40 | $r = $db->row('SELECT * FROM herd_vendors WHERE id = ?', $id); 41 | if (empty($r)) { 42 | throw new EmptyValueException('Could not find this product'); 43 | } 44 | return new static($r['name']); 45 | } 46 | 47 | /** 48 | * @param SigningPublicKey $publicKey 49 | * @return self 50 | */ 51 | public function appendPublicKey(SigningPublicKey $publicKey): self 52 | { 53 | $this->publicKeys[] = $publicKey; 54 | return $this; 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getPublicKeys(): array 61 | { 62 | return $this->publicKeys; 63 | } 64 | 65 | /** 66 | * @param EasyDB $db 67 | * @param string $name 68 | * @return int 69 | * @throws EmptyValueException 70 | */ 71 | public static function getVendorID(EasyDB $db, string $name = ''): int 72 | { 73 | /** @var int $id */ 74 | $id = $db->cell("SELECT id FROM herd_vendors WHERE name = ?", $name); 75 | if (!$id) { 76 | throw new EmptyValueException('No vendor found for this name'); 77 | } 78 | return (int) $id; 79 | } 80 | 81 | /** 82 | * @param EasyDB $db 83 | * @param int $vendorID 84 | * @param string $publicKey 85 | * @return int 86 | * @throws EmptyValueException 87 | */ 88 | public static function keySearch(EasyDB $db, int $vendorID, string $publicKey): int 89 | { 90 | /** @var array> $vendorKeys */ 91 | $vendorKeys = $db->run( 92 | "SELECT * FROM herd_vendor_keys WHERE trusted AND vendor = ?", 93 | $vendorID 94 | ); 95 | foreach ($vendorKeys as $vk) { 96 | /** @var array $vk */ 97 | if (\hash_equals($publicKey, $vk['publickey'])) { 98 | return (int) $vk['id']; 99 | } 100 | } 101 | throw new EmptyValueException('Public key not found for this vendor'); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/HerdTest.php: -------------------------------------------------------------------------------- 1 | db = Factory::create('sqlite:' . __DIR__ . '/empty.sql'); 30 | $this->db->beginTransaction(); 31 | } 32 | 33 | public function tearDown() 34 | { 35 | $this->db->rollBack(); 36 | } 37 | 38 | /** 39 | * @covers Herd::getLatestSummaryHash() 40 | */ 41 | public function testGetLatestSummaryHash() 42 | { 43 | $herd = new Herd( 44 | new Local($this->db), 45 | Config::fromFile(__DIR__ . '/config/empty.json') 46 | ); 47 | $prev = ''; 48 | 49 | $publickey = Base64UrlSafe::encode(random_bytes(32)); 50 | for ($i = 0; $i < 10; ++$i) { 51 | $this->assertSame($prev, $herd->getLatestSummaryHash()); 52 | 53 | $random = Base64UrlSafe::encode(random_bytes(32)); 54 | $summary = Base64UrlSafe::encode( 55 | \sodium_crypto_generichash($random, $prev) 56 | ); 57 | $signature = Base64UrlSafe::encode(random_bytes(32)); 58 | $this->db->insert( 59 | 'herd_history', 60 | [ 61 | 'hash' => $random, 62 | 'prevhash' => $prev, 63 | 'summaryhash' => $summary, 64 | 'contents' => 'Iteration #' . $i, 65 | 'publickey' => $publickey, 66 | 'signature' => $signature, 67 | 'created' => date(DATE_ATOM) 68 | ] 69 | ); 70 | $prev = $summary; 71 | } 72 | $this->assertSame($prev, $herd->getLatestSummaryHash()); 73 | } 74 | 75 | /** 76 | * @covers Herd::selectRemote() 77 | */ 78 | public function testSelectRemote() 79 | { 80 | $config = Config::fromFile(__DIR__ . '/config/valid.json'); 81 | $this->assertTrue(count($config->getRemotes()) > 0); 82 | $herd = new Herd( 83 | new Local(Factory::create('sqlite:' . __DIR__ . '/empty.sql')), 84 | $config 85 | ); 86 | 87 | $remote = $herd->selectRemote(true); 88 | $this->assertTrue($remote instanceof Remote); 89 | $this->assertFalse($remote->isPrimary()); 90 | } 91 | 92 | /** 93 | * @covers Herd::selectRemote() 94 | */ 95 | public function testSelectRemoteOnlyPrimaries() 96 | { 97 | $config = Config::fromFile(__DIR__ . '/config/only-primary-remote.json'); 98 | $this->assertTrue(count($config->getRemotes()) > 0); 99 | $herd = new Herd( 100 | new Local(Factory::create('sqlite:' . __DIR__ . '/empty.sql')), 101 | $config 102 | ); 103 | 104 | // This only has primaries. 105 | $remote = $herd->selectRemote(true); 106 | $this->assertTrue($remote instanceof Remote); 107 | $this->assertTrue($remote->isPrimary()); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/HistoryTest.php: -------------------------------------------------------------------------------- 1 | db = Factory::create('sqlite:' . __DIR__ . '/empty.sql'); 34 | $directory = dirname(__DIR__) . '/data/certs'; 35 | try { 36 | (new RemoteFetch($directory)) 37 | ->getLatestBundle() 38 | ->getFilePath(); 39 | } catch (CertaintyException $ex) { 40 | $this->fail('Test failed: could not download CACert bundle'); 41 | } catch (ConnectException $ex) { 42 | $this->markTestSkipped('Cannot connect using TLSv1.2'); 43 | } 44 | } 45 | 46 | /** 47 | * This may take some time to complete! 48 | * 49 | * @covers History::transcribe() 50 | */ 51 | public function testTranscribe() 52 | { 53 | $config = Config::fromFile(__DIR__ . '/config/public-test.json'); 54 | $this->assertTrue(count($config->getRemotes()) > 0); 55 | $herd = new Herd( 56 | new Local(Factory::create('sqlite:' . __DIR__ . '/empty.sql')), 57 | $config 58 | ); 59 | $history = new History($herd); 60 | 61 | $this->assertEquals('', $herd->getLatestSummaryHash()); 62 | $this->assertTrue($history->transcribe()); 63 | $this->assertNotEquals('', $herd->getLatestSummaryHash()); 64 | $this->db->query('DELETE FROM herd_history'); 65 | $this->assertEquals('', $herd->getLatestSummaryHash()); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/config/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "core-vendor": "paragonie", 3 | "remotes": [] 4 | } -------------------------------------------------------------------------------- /tests/config/only-primary-remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "core-vendor": "paragonie", 3 | "remotes": [ 4 | { 5 | "url": "https://chronicle-public-test.paragonie.com/chronicle", 6 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 7 | "primary": true 8 | }, 9 | { 10 | "url": "https://localhost", 11 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 12 | "primary": true 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /tests/config/public-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "core-vendor": "paragonie", 3 | "policies": { 4 | "core-vendor-manage-keys-allow": false, 5 | "core-vendor-manage-keys-auto": false, 6 | "quorum": 0 7 | }, 8 | "remotes": [ 9 | { 10 | "url": "https://chronicle-public-test.paragonie.com/chronicle", 11 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 12 | "primary": true 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /tests/config/valid.json: -------------------------------------------------------------------------------- 1 | { 2 | "core-vendor": "paragonie", 3 | "policies": { 4 | "core-vendor-manage-keys-allow": false, 5 | "core-vendor-manage-keys-auto": false, 6 | "quorum": 0 7 | }, 8 | "remotes": [ 9 | { 10 | "url": "https://chronicle-public-test.paragonie.com/chronicle", 11 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 12 | "primary": true 13 | }, 14 | { 15 | "url": "https://localhost", 16 | "public-key": "3BK4hOYTWJbLV5QdqS-DFKEYOMKd-G5M9BvfbqG1ICI=", 17 | "primary": false 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /tests/create-sqlite-database.php: -------------------------------------------------------------------------------- 1 | beginTransaction(); 17 | $contents = \file_get_contents(\dirname(__DIR__) . '/sql/sqlite/tables.sql'); 18 | if (!\is_string($contents)) { 19 | die('Could not read contents'); 20 | } 21 | $db->getPdo()->exec($contents); 22 | if (!$db->commit()) { 23 | var_dump($db->getPdo()->errorInfo()); 24 | } 25 | --------------------------------------------------------------------------------