├── .travis.yml ├── LICENSE ├── README.md ├── dev-requirements.txt ├── signet.py └── tests ├── __init__.py ├── fixtures ├── __init__.py ├── gpg │ ├── pubring.gpg │ └── secring.gpg ├── signet │ ├── keyring.gpg │ └── repo │ │ └── key ├── test.txt ├── test2.txt ├── test_secret_keyring.gpg └── work │ └── .empty ├── test_attestation.py ├── test_cli_integration.py ├── test_config.py └── test_gpg.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | 6 | install: "pip install -r dev-requirements.txt" 7 | 8 | script: 9 | - flake8 --max-line-length=240 . 10 | - coverage run --source=. -m unittest discover 11 | 12 | after_success: 13 | - pip install python-coveralls 14 | - coveralls 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Max Goodman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signet 2 | 3 | [![Build Status](https://img.shields.io/travis/chromakode/signet/master.svg?style=flat-square)](https://travis-ci.org/chromakode/signet) 4 | [![Coverage Status](https://img.shields.io/coveralls/chromakode/signet/master.svg?style=flat-square)](https://coveralls.io/github/chromakode/signet?branch=master) 5 | [![GitHub license](https://img.shields.io/github/license/chromakode/signet.svg?style=flat-square)](https://github.com/chromakode/signet/blob/master/LICENSE) 6 | 7 | Signet is a decentralized code signing network. Unlike traditional systems 8 | which focus on authors signing their releases, Signet enables third parties to 9 | publish cryptographically verified statements about software. These statements, 10 | called *attestations*, can be published to public respositories which enable 11 | global lookup and exchange. 12 | 13 | 14 | ## Purpose 15 | 16 | Validating the authenticity and security of open source packages is no simple 17 | task. How can we assure that the software we use hasn't been tampered with? 18 | How do we know it functions as expected? To answer these questions, code must 19 | be inspected in a time and labor intensive process. Reviews are typically 20 | carried out on a case-by-case basis by individuals and organizations. The 21 | results of such reviews are rarely published, and there is no standard way to 22 | publish the results of such reviews. 23 | 24 | The trustworthiness of the open source ecosystem is a massive shared problem. 25 | For large projects with formal release processes such as the Linux kernel, this 26 | problem is solved via gpg-signed releases. However, the long tail of github-hosted 27 | packages is unsigned. Even if all github users started signing their releases, 28 | this approach wouldn't work because there is no central authority (such as the 29 | official Linux signing key). The peer-to-peer development community needs a 30 | peer-to-peer trust system. 31 | 32 | Signet proposes to solve this problem by putting code signing in the hands of 33 | users instead of publishers. By opening this process up to the individuals and 34 | organizations who depend on open source software, participants in development 35 | communities can vouch for each other. Everyone who participates in or uses open 36 | source is a stakeholder in trustworthy ecosystem. Signet's aim is to combine 37 | everyone's knowledge and individual effort into a federated public index. 38 | 39 | Building a trust network is a human problem. Even with easily accessible code 40 | signatures, there remains the problem of determining who to trust and how to 41 | verify their identities. Building a large network (and the tooling required to 42 | maintain it) starts with small groups of people. If you're interested in 43 | helping tackle this, check out `sig`, a small utility which facilitates 44 | exchanging code signatures. 45 | 46 | 47 | ## `sig` 48 | 49 | :construction: `sig` is an early proof of concept. Please try it out and give 50 | feedback, but proceed with caution! :construction: 51 | 52 | The `sig` tool creates, validates, and publishes *attestations*, 53 | [gpg](https://gnupg.org)-signed JSON blobs which annotate files. Sig checks 54 | attestations with your set of trusted public keys to verify that software has 55 | been signed by yourself or trusted third party. 56 | 57 | To get started, configure `sig` with your public key: 58 | 59 | ```sh 60 | $ sig setup B32780D9 61 | Initialized config directory at: /home/demo/.signet/ 62 | With public key fingerprint: DFDD705124843F878C6183EBD8C1B99DB32780D9 63 | Welcome to Signet! :) 64 | ``` 65 | 66 | Let's create an attestation about a file we know about: 67 | 68 | ```sh 69 | $ sig attest ./signet.py 70 | I have reviewed this file (yes/no): yes 71 | It performs as expected and is free of major flaws (yes/no): yes 72 | Comment: My first attestation! 73 | Saved attestation for ./signet.py: 74 | { 75 | "comment": "My first attestation!", 76 | "id": "sha256:7c47d79b2e292c509fcdd546ea42427fe3cb02aca40577c1bd8c6f61948c28eb", 77 | "ok": true, 78 | "reviewed": true 79 | } 80 | ``` 81 | 82 | Now to verify the same file: 83 | 84 | ```sh 85 | $ sig verify ./signet.py 86 | identified file as sha256:7c47d79b2e292c509fcdd546ea42427fe3cb02aca40577c1bd8c6f61948c28eb 87 | ok [B32780D9] Dev Test Key 88 | file /mnt/shared/signet.py is ok. 89 | ``` 90 | 91 | Your attestations are stored in a *repository* at `~/.signet/repo`. You can 92 | publish this directory on the internet to share it with other people. 93 | 94 | Let's fetch another repository: 95 | 96 | ```sh 97 | $ sig remote add hello-world http://world.com/signet/ 98 | $ sig fetch 99 | GET http://world.com/signet/repo.json... done. 100 | GET http://world.com/signet/key.asc... done. 101 | Imported [D9886717] Hello World . 102 | ``` 103 | 104 | Now if we verify a file they have signed: 105 | 106 | ```sh 107 | $ sig verify ./test.txt 108 | identified file as sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada 109 | untrusted [D9886717] Hello World 110 | file /mnt/shared/signet.py is not verified. 111 | ``` 112 | 113 | We need to mark the new repo's key as trusted before `sig verify` will use its 114 | attestations: 115 | 116 | ```sh 117 | $ sig trust add B32780D9 118 | Trusted: [D9886717] Hello World 119 | ``` 120 | 121 | If we verify the file again, we can see that it is now accepted. 122 | 123 | ```sh 124 | $ sig verify ./test.txt 125 | identified file as sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada 126 | ok [D9886717] Hello World 127 | file /mnt/shared/signet.py is ok. 128 | ``` 129 | 130 | A complete listing of available `sig` commands can be viewed via `sig help`. 131 | 132 | 133 | ## Data Formats 134 | 135 | ### Attestations 136 | 137 | An attestation is a signed JSON blob of arbitrary data. It consists of the 138 | following properties: 139 | 140 | ```js 141 | { 142 | "data": { ... }, 143 | "key": "full gpg key fingerprint (SHA1)", 144 | "sig": "base64-encoded gpg signature of json-encoded data property (keys sorted alphabetically)" 145 | } 146 | ``` 147 | 148 | Currently, `sig` records the following data in attestations: 149 | 150 | ```js 151 | "data": { 152 | "comment": "My first attestation!", 153 | "id": "sha256:7c47d79b2e292c509fcdd546ea42427fe3cb02aca40577c1bd8c6f61948c28eb", 154 | 155 | // is this file trustworthy, and does it perform as expected? 156 | "ok": true, 157 | 158 | // has this file been reviewed by a human? 159 | "reviewed": true 160 | }, 161 | ``` 162 | 163 | ### Repositories 164 | 165 | A repository stores a mapping of *identifiers* (file content hashes) to 166 | *attestations*. This is wrapped inside its own attestation made by the owner of 167 | the repository. It also contains a version number to aid future format changes. 168 | 169 | ```js 170 | { 171 | "data": { 172 | "attestations": { 173 | "sha256:7c47d79b2e292c509fcdd546ea42427fe3cb02aca40577c1bd8c6f61948c28eb": [ 174 | { 175 | "data": { ... }, 176 | "key": "...", 177 | "sig": "..." 178 | } 179 | ], 180 | "sha256:91751cee0a1ab8414400238a761411daa29643ab4b8243e9a91649e25be53ada": [ 181 | { 182 | "data": { ... }, 183 | "key": "...", 184 | "sig": "..." 185 | } 186 | ] 187 | }, 188 | "version": "0.0.1" 189 | }, 190 | "key": "repo attestation key fingerprint", 191 | "sig": "repo attestation signature" 192 | } 193 | ``` 194 | 195 | 196 | ### Future plans 197 | 198 | Please the [issues listing](https://github.com/chromakode/signet/issues) for 199 | planned features and open projects. 200 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8==2.2.4 2 | mccabe==0.4.0 3 | pep8==1.7.0 4 | pyflakes==1.1.0 5 | mock==1.0.1 6 | coverage==4.0.3 7 | -------------------------------------------------------------------------------- /signet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python2 2 | from __future__ import print_function 3 | import argparse 4 | import base64 5 | import copy 6 | import hashlib 7 | import json 8 | import logging 9 | import logging.handlers 10 | import os 11 | import subprocess 12 | import sys 13 | import urllib2 14 | import urlparse 15 | from collections import defaultdict 16 | from datetime import datetime 17 | 18 | 19 | __version__ = '0.0.1' 20 | 21 | 22 | READ_SIZE = 65536 23 | LOGGER = logging.getLogger('sig') 24 | 25 | 26 | def touch_dir(path): 27 | if not os.path.exists(path): 28 | os.mkdir(path) 29 | 30 | 31 | def run(args, data=None, **kwargs): 32 | LOGGER.getChild('run').debug(' '.join(args)) 33 | proc = subprocess.Popen( 34 | args, 35 | stdin=subprocess.PIPE if data is not None else None, 36 | stdout=subprocess.PIPE, 37 | stderr=subprocess.PIPE, 38 | **kwargs 39 | ) 40 | out, err = proc.communicate(data) 41 | return out, err, proc.returncode 42 | 43 | 44 | TERM_FORMATS = { 45 | 'BOLD': '1', 46 | 'FAINT': '2', 47 | 'BLACK': '30', 48 | 'RED': '31', 49 | 'GREEN': '32', 50 | 'YELLOW': '33', 51 | 'BLUE': '34', 52 | 'MAGENTA': '35', 53 | 'CYAN': '36', 54 | 'WHITE': '37', 55 | } 56 | 57 | 58 | def color(text, *codes): 59 | parts = [] 60 | parts.append('\033[') 61 | parts.append(';'.join(TERM_FORMATS[code] for code in codes)) 62 | parts.append('m') 63 | parts.append(text) 64 | parts.append('\033[0m') 65 | return ''.join(parts) 66 | 67 | 68 | class GPGError(Exception): 69 | pass 70 | 71 | 72 | class GPGExitError(GPGError): 73 | pass 74 | 75 | 76 | class GPGParseError(GPGError): 77 | pass 78 | 79 | 80 | class GPGKeyNotFoundError(GPGError): 81 | pass 82 | 83 | 84 | class GPGInvalidSignatureError(GPGError): 85 | pass 86 | 87 | 88 | def gpg_run(args, data=None, keyring=None, secret_keyring=None, default_keyring=False): 89 | base_args = ['gpg', '--status-fd', '2'] 90 | 91 | if not default_keyring: 92 | base_args.append('--no-default-keyring') 93 | 94 | if keyring: 95 | base_args.append('--keyring') 96 | base_args.append(keyring) 97 | 98 | if secret_keyring: 99 | base_args.append('--secret-keyring') 100 | base_args.append(secret_keyring) 101 | 102 | return run(base_args + args, data) 103 | 104 | 105 | def gpg_parse_status(err): 106 | status = {} 107 | for line in err.decode('ascii').split('\n'): 108 | if line.startswith('[GNUPG:] '): 109 | fields = line.split(' ', 2) 110 | status[fields[1]] = fields[2] if len(fields) > 2 else True 111 | 112 | return status 113 | 114 | 115 | def gpg_parse_colons(output): 116 | lines = output.decode('ascii').split('\n') 117 | sections = {} 118 | for line in lines: 119 | section, sep, content = line.partition(':') 120 | sections[section] = content.split(':') 121 | 122 | return { 123 | 'uid': sections['pub'][8], 124 | 'fingerprint': sections['fpr'][8], 125 | } 126 | 127 | 128 | def gpg_check_run(*args, **kwargs): 129 | out, err, returncode = gpg_run(*args, **kwargs) 130 | if returncode != 0: 131 | raise GPGExitError(err.decode('ascii')) 132 | return out, err 133 | 134 | 135 | def gpg_get_ascii_public_key(keyid, keyring=None): 136 | out, err = gpg_check_run([ 137 | '--export', 138 | '--armor', 139 | keyid, 140 | ], keyring=keyring) 141 | if not out: 142 | raise GPGKeyNotFoundError 143 | return out 144 | 145 | 146 | def gpg_get_key_info(keyid, keyring=None): 147 | LOGGER.getChild('gpg').debug('getting key info for {}'.format(keyid)) 148 | try: 149 | out, err = gpg_check_run([ 150 | '--with-colons', 151 | '--with-fingerprint', 152 | '--list-keys', 153 | keyid, 154 | ], keyring=keyring) 155 | except GPGExitError as e: 156 | if e.args[0] == 'gpg: error reading key: public key not found\n': 157 | raise GPGKeyNotFoundError 158 | else: 159 | raise 160 | return gpg_parse_colons(out) 161 | 162 | 163 | def gpg_import_key(path, keyring=None): 164 | LOGGER.getChild('gpg').debug('importing key from {} to {}'.format(path, keyring)) 165 | out, err = gpg_check_run([ 166 | '--import', 167 | path, 168 | ], keyring=keyring) 169 | status = gpg_parse_status(err) 170 | return status['IMPORT_OK'].split(' ')[1] 171 | 172 | 173 | def gpg_sign(keyid, text, keyring=None, secret_keyring=None): 174 | out, err = gpg_check_run([ 175 | '--detach-sign', 176 | '--digest-algo=sha256', 177 | '-u', 178 | keyid, 179 | '-o', 180 | '-', 181 | '-', 182 | ], text.encode('ascii'), keyring, secret_keyring) 183 | return out 184 | 185 | 186 | def gpg_verify(keyid, text, sig_data, keyring=None, default_keyring=False): 187 | pipe_rfd, pipe_wfd = os.pipe() 188 | os.write(pipe_wfd, text.encode('ascii')) 189 | os.close(pipe_wfd) 190 | out, err, returncode = gpg_run([ 191 | '--verify', 192 | '--enable-special-filenames', 193 | '-', 194 | '-&{}'.format(pipe_rfd), 195 | ], sig_data, keyring, default_keyring=default_keyring) 196 | os.close(pipe_rfd) 197 | 198 | status = gpg_parse_status(err) 199 | 200 | if 'NO_PUBKEY' in status: 201 | raise GPGKeyNotFoundError('Unknown key') 202 | 203 | if 'VALIDSIG' not in status: 204 | raise GPGInvalidSignatureError('Invalid signature') 205 | 206 | validsig = status['VALIDSIG'].split(' ') 207 | sig_keyid = validsig[0] 208 | if sig_keyid != keyid: 209 | raise GPGInvalidSignatureError( 210 | 'Key mismatch: got {}; expected {}'.format(sig_keyid, keyid) 211 | ) 212 | 213 | # FIXME: this can apparently sometimes be an ISO 8601 string 214 | timestamp = datetime.fromtimestamp(int(validsig[2])) 215 | return timestamp 216 | 217 | 218 | def make_attestation(data, keyid, keyring=None, secret_keyring=None): 219 | json_text = json.dumps(data, sort_keys=True) 220 | LOGGER.getChild('attestation').debug('signing as {}'.format(keyid)) 221 | sig_data = gpg_sign(keyid, json_text, keyring, secret_keyring) 222 | 223 | attestation = { 224 | 'data': data, 225 | 'sig': base64.b64encode(sig_data).decode('ascii'), 226 | 'key': keyid, 227 | } 228 | 229 | return attestation 230 | 231 | 232 | def verify_attestation(attestation, keyring=None): 233 | sig_data = base64.b64decode(attestation['sig']) 234 | json_text = json.dumps(attestation['data'], sort_keys=True) 235 | LOGGER.getChild('attestation').debug('verifying from {}'.format(attestation['key'])) 236 | return gpg_verify(attestation['key'], json_text, sig_data, keyring) 237 | 238 | 239 | def identify(file_or_path): 240 | sha = hashlib.sha256() 241 | 242 | def read(f): 243 | while True: 244 | data = f.read(READ_SIZE) 245 | if not data: 246 | break 247 | sha.update(data) 248 | 249 | if type(file_or_path) is file: 250 | read(file_or_path) 251 | else: 252 | with open(file_or_path, 'rb') as f: 253 | read(f) 254 | 255 | return 'sha256:' + sha.hexdigest(), 'file' 256 | 257 | 258 | class RepoNotFoundError(Exception): 259 | pass 260 | 261 | 262 | class RepoUnreadableError(Exception): 263 | pass 264 | 265 | 266 | class Repo(object): 267 | def __init__(self, path, keyid, keyring, secret_keyring): 268 | self.path = path 269 | self.keyid = keyid 270 | self.keyring = keyring 271 | self.secret_keyring = secret_keyring 272 | self.data = None 273 | 274 | def init_defaults(self): 275 | self.data = {} 276 | self.data['attestations'] = {} 277 | self.data['version'] = __version__ 278 | 279 | def load(self): 280 | if not os.path.exists(self.path): 281 | raise RepoNotFoundError 282 | 283 | with open(self.path) as f: 284 | try: 285 | attestation = json.load(f) 286 | except ValueError: 287 | raise RepoUnreadableError 288 | 289 | verify_attestation(attestation, self.keyring) 290 | LOGGER.getChild('repo').debug('attestation ok') 291 | self.data = attestation['data'] 292 | 293 | def save(self): 294 | LOGGER.getChild('repo').debug('saving to {} with keyid {}'.format(self.path, self.keyid)) 295 | 296 | repo_attestation = make_attestation( 297 | self.data, 298 | self.keyid, 299 | self.keyring, 300 | self.secret_keyring, 301 | ) 302 | 303 | with open(self.path, 'w') as f: 304 | json.dump(repo_attestation, f) 305 | 306 | def lookup(self, identity): 307 | return self.data['attestations'].get(identity) 308 | 309 | def add(self, attestation): 310 | identifier = attestation['data']['id'] 311 | atts = self.data['attestations'].setdefault(identifier, []) 312 | atts.append(attestation) 313 | 314 | 315 | class RepoSet(object): 316 | def __init__(self, repos): 317 | self.repos = repos 318 | 319 | def lookup(self, identity): 320 | results = [] 321 | seen_sigs = set() 322 | for repo in self.repos: 323 | attestation_list = repo.lookup(identity) 324 | if not attestation_list: 325 | continue 326 | 327 | for attestation in attestation_list: 328 | if attestation['sig'] not in seen_sigs: 329 | results.append(attestation) 330 | seen_sigs.add(attestation['sig']) 331 | 332 | return results 333 | 334 | 335 | class NoConfigError(Exception): 336 | pass 337 | 338 | 339 | class ConfigExistsError(Exception): 340 | pass 341 | 342 | 343 | class Config(object): 344 | DEFAULTS = { 345 | 'repo_dir': 'repo/', 346 | 'secret_keyring': None, 347 | 'policy': {'ok': 1, 'not-ok': 0}, 348 | 'trust': {}, 349 | 'remotes': {}, 350 | } 351 | 352 | def __init__(self, config_dir, keyid=None): 353 | self.config_dir = config_dir 354 | self.config = None 355 | self.keyid = keyid 356 | 357 | def __getitem__(self, key): 358 | return self.config[key] 359 | 360 | def __setitem__(self, key, value): 361 | self.config[key] = value 362 | 363 | def __contains__(self, key): 364 | return key in self.config or key in self.DEFAULTS 365 | 366 | def path(self, name): 367 | return os.path.join(self.config_dir, name) 368 | 369 | @property 370 | def repo_path(self): 371 | return self.path(os.path.join(self['repo_dir'], 'repo.json')) 372 | 373 | @property 374 | def keyring_path(self): 375 | return self.path('keyring.gpg') 376 | 377 | def _check_path(self, name): 378 | if not os.path.exists(self.path(name)): 379 | raise NoConfigError('Could not find {}'.format(name)) 380 | 381 | def init_defaults(self): 382 | self.config = copy.deepcopy(self.DEFAULTS) 383 | 384 | def load(self): 385 | self._check_path('') 386 | self._check_path('config') 387 | self._check_path('keyring.gpg') 388 | 389 | with open(self.path('config')) as f: 390 | config_attestation = json.load(f) 391 | 392 | verify_attestation(config_attestation, self.keyring_path) 393 | LOGGER.getChild('config').debug('attestation ok') 394 | self.config = {} 395 | self.config.update(self.DEFAULTS) 396 | self.config.update(config_attestation['data']) 397 | self.keyid = config_attestation['key'] 398 | 399 | def save(self): 400 | LOGGER.getChild('config').debug('saving to {} with keyid {}'.format(self.path('config'), self.keyid)) 401 | 402 | self._check_path('') 403 | self._check_path('keyring.gpg') 404 | 405 | if not self.keyid: 406 | raise NoConfigError('No key specified') 407 | 408 | self.config['version'] = __version__ 409 | 410 | config_attestation = make_attestation( 411 | self.config, 412 | self.keyid, 413 | self.keyring_path, 414 | self['secret_keyring'], 415 | ) 416 | 417 | with open(self.path('config'), 'w') as f: 418 | json.dump(config_attestation, f) 419 | 420 | def get_repo(self, path): 421 | return Repo( 422 | path, 423 | self.keyid, 424 | self.keyring_path, 425 | self.config['secret_keyring'], 426 | ) 427 | 428 | 429 | class Sig(object): 430 | def load(self, config_dir=None): 431 | full_config_dir = os.path.expanduser(config_dir or '~/.signet') 432 | self.config = Config(full_config_dir) 433 | self.config.load() 434 | self.own_repo = self.config.get_repo(self.config.repo_path) 435 | self.own_repo.load() 436 | 437 | repos = [self.own_repo] 438 | failed_repos = {} 439 | for remote_name in self.config['remotes']: 440 | repo_path = self.config.path('remotes/{}/repo.json'.format(remote_name)) 441 | remote_repo = self.config.get_repo(repo_path) 442 | try: 443 | # TODO: handle signature errors 444 | remote_repo.load() 445 | except RepoNotFoundError: 446 | # ok to ignore for now, repo might not be fetched yet 447 | pass 448 | except RepoUnreadableError as e: 449 | failed_repos[remote_name] = e 450 | continue 451 | repos.append(remote_repo) 452 | self.repos = RepoSet(repos) 453 | return failed_repos 454 | 455 | def setup_config_dir(self, keyid, config_dir=None): 456 | full_config_dir = os.path.expanduser(config_dir or '~/.signet') 457 | self.config = Config(full_config_dir, keyid=keyid) 458 | 459 | config_path = self.config.path('config') 460 | if os.path.exists(config_path): 461 | raise ConfigExistsError('Existing config detected at: {}'.format(config_path)) 462 | 463 | public_key = gpg_get_ascii_public_key(keyid) 464 | 465 | touch_dir(self.config.path('')) 466 | touch_dir(self.config.path('repo')) 467 | touch_dir(self.config.path('remotes')) 468 | 469 | with open(self.config.path('repo/key.asc'), 'wb') as keyring_file: 470 | keyring_file.write(public_key) 471 | 472 | gpg_import_key(self.config.path('repo/key.asc'), self.config.keyring_path) 473 | 474 | self.config.init_defaults() 475 | self.config['trust'][keyid] = True 476 | 477 | self.config.save() 478 | 479 | own_repo = self.config.get_repo(self.config.repo_path) 480 | own_repo.init_defaults() 481 | own_repo.save() 482 | 483 | def get_key_info(self, keyid): 484 | return gpg_get_key_info(keyid, self.config.keyring_path) 485 | 486 | def attest(self, data): 487 | attestation = make_attestation(data, self.config.keyid, self.config.keyring_path) 488 | self.own_repo.add(attestation) 489 | self.own_repo.save() 490 | 491 | def validate(self, identifier): 492 | matches = {} 493 | 494 | def record_match(keyid, ts, kind): 495 | if keyid not in matches or ts > matches[keyid]['ts']: 496 | matches[keyid] = {'ts': ts, 'kind': kind} 497 | 498 | attestation_list = self.repos.lookup(identifier) 499 | 500 | for attestation in attestation_list: 501 | keyid = attestation['key'] 502 | try: 503 | ts = verify_attestation(attestation, self.config.keyring_path) 504 | except GPGKeyNotFoundError: 505 | record_match(keyid, datetime.min, 'unknown') 506 | continue 507 | except GPGInvalidSignatureError: 508 | record_match(keyid, datetime.min, 'invalid') 509 | continue 510 | 511 | if attestation['data']['reviewed'] is not True: 512 | continue 513 | 514 | if self.config['trust'].get(keyid) is not True: 515 | record_match(keyid, ts, 'untrusted') 516 | continue 517 | 518 | if attestation['data']['ok'] is not True: 519 | record_match(keyid, ts, 'not-ok') 520 | continue 521 | 522 | record_match(keyid, ts, 'ok') 523 | 524 | categories = defaultdict(list) 525 | for keyid, match in matches.iteritems(): 526 | categories[match['kind']].append(keyid) 527 | 528 | policy = self.config['policy'] 529 | if not any(len(keys) > 0 for keys in categories.itervalues()): 530 | valid = None 531 | else: 532 | valid = len(categories['ok']) >= policy['ok'] and len(categories['not-ok']) <= policy['not-ok'] 533 | return valid, categories 534 | 535 | def fetch_remote(self, name, remote, status_callback): 536 | def download(url): 537 | status_callback('start', {'url': url}) 538 | resp = urllib2.urlopen(url) 539 | 540 | data = [] 541 | while True: 542 | chunk = resp.read(READ_SIZE) 543 | if not chunk: 544 | break 545 | data.append(chunk) 546 | 547 | status_callback('finish', {'url': url}) 548 | return ''.join(data) 549 | 550 | repo_url = urlparse.urljoin(remote['url'], 'repo.json') 551 | repo_dir = self.config.path('remotes/{}'.format(name)) 552 | touch_dir(repo_dir) 553 | repo_dest = os.path.join(repo_dir, 'repo.json') 554 | repo_data = download(repo_url) 555 | 556 | try: 557 | attestation = json.loads(repo_data) 558 | except ValueError as e: 559 | raise RepoUnreadableError(e) 560 | 561 | with open(repo_dest, 'w') as f: 562 | f.write(repo_data) 563 | 564 | try: 565 | gpg_get_key_info(attestation['key'], keyring=self.config.keyring_path) 566 | except GPGKeyNotFoundError: 567 | key_url = urlparse.urljoin(remote['url'], 'key.asc') 568 | key_dest = os.path.join(repo_dir, 'key.asc') 569 | download(key_url, key_dest) 570 | # TODO: check that key fingerprint matches expected 571 | imported_key_id = gpg_import_key(key_dest, keyring=self.config.keyring_path) 572 | status_callback('import', {'keyid': imported_key_id}) 573 | 574 | 575 | class CLILogFormatter(logging.Formatter): 576 | def __init__(self, color_func, verbose=False): 577 | self._c = color_func 578 | logging.Formatter.__init__(self) 579 | 580 | def format(self, record): 581 | text = [] 582 | 583 | if record.levelno == logging.CRITICAL: 584 | text.append(self._c('!!!', 'BOLD', 'RED')) 585 | elif record.levelno == logging.ERROR: 586 | text.append(self._c('err', 'RED')) 587 | elif record.levelno == logging.WARNING: 588 | text.append(self._c('warning', 'YELLOW')) 589 | 590 | if record.name.startswith('sig.cli'): 591 | body = str(record.msg) 592 | else: 593 | body = '{name}: {message}'.format( 594 | name=record.name.replace('sig.', ''), 595 | message=record.msg, 596 | ) 597 | 598 | if record.levelno == logging.DEBUG: 599 | text.append(self._c(body, 'FAINT')) 600 | else: 601 | text.append(body) 602 | 603 | return ' '.join(text) 604 | 605 | 606 | class SigCLI(object): 607 | def __init__(self): 608 | self.sig = Sig() 609 | self.quiet = False 610 | self.use_color = True 611 | self.log = LOGGER.getChild('cli') 612 | 613 | def _c(self, text, *codes): 614 | if self.use_color: 615 | return color(text, *codes) 616 | return text 617 | 618 | def _init_args_parser(self): 619 | parser = argparse.ArgumentParser(prog='sig', formatter_class=WideHelpFormatter) 620 | parser.add_argument('--verbose', '-v', action='store_true', help='output detailed logs') 621 | parser.add_argument('--quiet', '-q', action='store_true', help='suppress status output') 622 | parser.add_argument('--no-color', action='store_true', help='don\'t colorize output') 623 | parser.set_defaults(subcommand=None) 624 | 625 | subparsers = parser.add_subparsers(title='subcommands', metavar='', dest='command') 626 | 627 | parser_attest = subparsers.add_parser('setup', help='initialize configuration') 628 | parser_attest.add_argument('keyid') 629 | 630 | parser_verify = subparsers.add_parser('verify', help='check whether a resource is trusted') 631 | parser_verify.add_argument('file', nargs='?', default='-', help='a path to verify, or - for stdin') 632 | 633 | parser_attest = subparsers.add_parser('attest', help='sign a statement about a resource') 634 | parser_attest.add_argument('file') 635 | 636 | parser_fetch = subparsers.add_parser('fetch', help='download attestations from remotes') 637 | parser_fetch.add_argument('name', nargs='*', help='name of remote to fetch') 638 | 639 | subparsers.add_parser('publish', help='send attestations to remotes') 640 | 641 | parser_config = subparsers.add_parser('config', help='get and set configuration values', description=''' 642 | When only a key is specified, the value of the config parameter is returned. 643 | If a value is specified, the config parameter is updated.''') 644 | parser_config.add_argument('key') 645 | parser_config.add_argument('value', nargs=argparse.REMAINDER) 646 | 647 | parser_trust = subparsers.add_parser('trust', help='manage trusted keys') 648 | subparsers_trust = parser_trust.add_subparsers(title='subcommands', metavar='', dest='subcommand') 649 | 650 | subparsers_trust.add_parser('list', help='list trusted key policies') 651 | 652 | parser_trust_add = subparsers_trust.add_parser('add', help='trust a key when verifying resources') 653 | parser_trust_add.add_argument('keyid', help='id of the key to add') 654 | 655 | parser_trust_remove = subparsers_trust.add_parser('remove', help='remove trusted key') 656 | parser_trust_remove.add_argument('keyid', help='id of the key to remote') 657 | 658 | parser_remote = subparsers.add_parser('remote', help='manage remote repositories') 659 | subparsers_remote = parser_remote.add_subparsers(title='subcommands', metavar='', dest='subcommand') 660 | 661 | subparsers_remote.add_parser('list', help='list configured remote repositories') 662 | 663 | parser_remote_add = subparsers_remote.add_parser('add', help='add a remote signet repository') 664 | parser_remote_add.add_argument('name', help='a nickname to refer to the remote') 665 | parser_remote_add.add_argument('url', help='the url of the remote') 666 | 667 | parser_remote_remove = subparsers_remote.add_parser('remove', help='remove a remote') 668 | parser_remote_remove.add_argument('name') 669 | 670 | parser_help = subparsers.add_parser('help', help='display this help') 671 | parser_help.add_argument('topic', nargs=argparse.REMAINDER, help='a command to display help for') 672 | 673 | return parser 674 | 675 | def load(self): 676 | failed_repos = self.sig.load(os.environ.get('SIG_DIR')) 677 | for remote_name, reason in failed_repos.iteritems(): 678 | if type(reason) is RepoUnreadableError: 679 | self.log.warning('Unable to load remote repo "{}". Skipped.'.format(remote_name)) 680 | 681 | def run(self, argv=None): 682 | parser = self._init_args_parser() 683 | 684 | args = parser.parse_args(argv) 685 | self.use_color = not args.no_color 686 | 687 | if args.command == 'help': 688 | parser.parse_args(args.topic + ['-h']) 689 | return 690 | 691 | command = args.command 692 | if args.subcommand: 693 | command += '_' + args.subcommand 694 | 695 | log_output = logging.StreamHandler(stream=sys.stdout) 696 | log_output.setFormatter(CLILogFormatter( 697 | color_func=self._c, 698 | verbose=args.verbose, 699 | )) 700 | self.quiet = args.quiet 701 | if args.verbose: 702 | LOGGER.setLevel(logging.DEBUG) 703 | elif args.quiet: 704 | LOGGER.setLevel(logging.CRITICAL) 705 | else: 706 | LOGGER.setLevel(logging.INFO) 707 | LOGGER.addHandler(log_output) 708 | 709 | if command != 'setup': 710 | self.load() 711 | 712 | getattr(self, 'cmd_' + command)(args) 713 | 714 | def _summarize_key(self, keyid): 715 | key_info = self.sig.get_key_info(keyid) 716 | return '[{fp}] {uid}'.format( 717 | uid=key_info['uid'], 718 | fp=key_info['fingerprint'][-8:] 719 | ) 720 | 721 | def _lookup_key(self, keyid, keyring): 722 | try: 723 | return gpg_get_key_info(keyid, keyring=keyring) 724 | except GPGKeyNotFoundError: 725 | self.log.error('Key not found: {}'.format(keyid)) 726 | sys.exit(1) 727 | 728 | def cmd_setup(self, args): 729 | full_keyid = self._lookup_key(args.keyid, keyring=None)['fingerprint'] 730 | 731 | try: 732 | self.sig.setup_config_dir(full_keyid, os.environ.get('SIG_DIR')) 733 | except ConfigExistsError as e: 734 | self.log.error(e) 735 | self.log.info('Aborting.') 736 | sys.exit(1) 737 | 738 | self.log.info('Initialized config directory at: {}'.format(self.sig.config.path(''))) 739 | self.log.info('With public key fingerprint: {}'.format(full_keyid)) 740 | self.log.info('Welcome to Signet! :)') 741 | 742 | def cmd_attest(self, args): 743 | identifier, kind = identify(args.file) 744 | 745 | reviewed = None 746 | while reviewed not in ['yes', 'no']: 747 | reviewed = raw_input('I have reviewed this {} (yes/no): '.format(kind)) 748 | 749 | if not reviewed == 'yes': 750 | self.log.info('Please review the file') 751 | return 752 | 753 | ok = None 754 | while ok not in ['yes', 'no']: 755 | ok = raw_input('It performs as expected and is free of major flaws (yes/no): '.format(kind)) 756 | 757 | comment = raw_input('Comment: ') 758 | 759 | data = { 760 | 'id': identifier, 761 | 'ok': ok == 'yes', 762 | 'reviewed': True, 763 | } 764 | 765 | if comment: 766 | data['comment'] = comment 767 | 768 | self.sig.attest(data) 769 | self.log.info('Saved attestation for {}:'.format(args.file)) 770 | self.log.info(json.dumps(data, indent=2, sort_keys=True)) 771 | 772 | def cmd_verify(self, args): 773 | # TODO: add verbose mode to display comments, timestamps, replacements, etc 774 | file_path = os.path.abspath(args.file) 775 | input_file = sys.stdin if args.file == '-' else file_path 776 | identifier, kind = identify(input_file) 777 | 778 | self.log.info('identified {} as {}'.format(kind, identifier)) 779 | 780 | ok, matches = self.sig.validate(identifier) 781 | 782 | if ok is None: 783 | self.log.info('{} {} is {}!'.format(kind, file_path, self._c('unknown', 'YELLOW'))) 784 | sys.exit(1) 785 | 786 | for category in ('unknown', 'invalid'): 787 | count = len(matches[category]) 788 | if count > 0: 789 | self.log.info('{}: {}'.format(category, count)) 790 | 791 | for category in ('ok', 'not-ok', 'untrusted'): 792 | for keyid in matches[category]: 793 | self.log.info('{} {}'.format(category, self._summarize_key(keyid))) 794 | 795 | if not ok: 796 | if len(matches['not-ok']) > 0: 797 | self.log.critical('{} {} is {}!'.format(kind, file_path, self._c('marked bad', 'BOLD', 'RED'))) 798 | else: 799 | self.log.info('{} {} is {}.'.format(kind, file_path, self._c('not verified', 'YELLOW'))) 800 | sys.exit(1) 801 | 802 | self.log.info('{} {} is {}.'.format(kind, file_path, self._c('ok', 'BOLD', 'GREEN'))) 803 | 804 | def cmd_fetch(self, args): 805 | def print_status(event, details): 806 | if self.quiet: 807 | return 808 | 809 | if event == 'start': 810 | print('GET {}... '.format(details['url']), end='') 811 | elif event == 'finish': 812 | print('done.') 813 | elif event == 'import': 814 | print('Imported {}.'.format(self._summarize_key(details['keyid']))) 815 | 816 | for name, remote in self.sig.config['remotes'].iteritems(): 817 | if args.name and name not in args.name: 818 | continue 819 | 820 | try: 821 | self.sig.fetch_remote(name, remote, print_status) 822 | except urllib2.HTTPError as e: 823 | if not self.quiet: 824 | print(self._c(str(e), 'RED')) 825 | except RepoUnreadableError as e: 826 | self.log.warning('Unable to read remote repo "{}": {}'.format(name, e)) 827 | 828 | def cmd_publish(self, args): 829 | self.log.info('Signet servers are not ready yet.') 830 | self.log.info('In the mean time, copy {} to a web server.'.format(self.sig.config['repo_dir'])) 831 | 832 | def cmd_config(self, args): 833 | if args.key and args.key not in self.sig.config: 834 | self.log.error('Unknown key: {}'.format(args.key)) 835 | sys.exit(1) 836 | 837 | if args.key and args.value: 838 | self.sig.config[args.key] = json.loads(' '.join(args.value)) 839 | self.sig.config.save() 840 | return 841 | 842 | if args.key: 843 | self.log.info(json.dumps(self.sig.config[args.key], sort_keys=True)) 844 | return 845 | 846 | def cmd_trust_list(self, args): 847 | for keyid, policy in self.sig.config['trust'].iteritems(): 848 | self.log.info(self._summarize_key(keyid)) 849 | 850 | def cmd_trust_add(self, args): 851 | full_keyid = self._lookup_key(args.keyid, keyring=self.sig.config.keyring_path)['fingerprint'] 852 | 853 | if self.sig.config['trust'].get(full_keyid) is True: 854 | self.log.warning('Already trusted. Skipping.') 855 | return 856 | 857 | self.sig.config['trust'][full_keyid] = True 858 | self.sig.config.save() 859 | self.log.info('Trusted: {}'.format(self._summarize_key(full_keyid))) 860 | 861 | def cmd_trust_remove(self, args): 862 | full_keyid = self._lookup_key(args.keyid, keyring=self.sig.config.keyring_path)['fingerprint'] 863 | 864 | if full_keyid not in self.sig.config['trust']: 865 | self.log.warning('Not trusted. Skipping.') 866 | return 867 | 868 | del self.sig.config['trust'][full_keyid] 869 | self.sig.config.save() 870 | self.log.info('Removed: {}'.format(self._summarize_key(full_keyid))) 871 | 872 | def cmd_remote_list(self, args): 873 | for name, config in self.sig.config['remotes'].iteritems(): 874 | self.log.info('"{}" {}'.format(name, config['url'])) 875 | 876 | def cmd_remote_add(self, args): 877 | if args.name in self.sig.config['remotes']: 878 | self.log.warning('A remote named "{}" already exists. Skipping.'.format(args.name)) 879 | return 880 | 881 | self.sig.config['remotes'][args.name] = {'url': args.url} 882 | self.sig.config.save() 883 | 884 | def cmd_remote_remove(self, args): 885 | if args.name not in self.sig.config['remotes']: 886 | self.log.warning('No remote named "{}" exists. Skipping.'.format(args.name)) 887 | return 888 | 889 | del self.sig.config['remotes'][args.name] 890 | self.sig.config.save() 891 | 892 | 893 | class WideHelpFormatter(argparse.HelpFormatter): 894 | def __init__(self, *args, **kwargs): 895 | argparse.HelpFormatter.__init__(self, *args, **kwargs) 896 | self._action_max_length = 18 897 | 898 | 899 | if __name__ == '__main__': 900 | SigCLI().run() 901 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from . import fixtures 2 | 3 | 4 | TEST_KEYRING = fixtures.path('signet/keyring.gpg') 5 | TEST_SECRET_KEYRING = fixtures.path('test_secret_keyring.gpg') 6 | TEST_KEYID = '9C75CB915794A44DD7697E21571D8816D9886717' 7 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | root_path = os.path.dirname(__file__) 5 | 6 | 7 | def path(name): 8 | return os.path.join(root_path, name) 9 | -------------------------------------------------------------------------------- /tests/fixtures/gpg/pubring.gpg: -------------------------------------------------------------------------------- 1 | ../signet/keyring.gpg -------------------------------------------------------------------------------- /tests/fixtures/gpg/secring.gpg: -------------------------------------------------------------------------------- 1 | ../test_secret_keyring.gpg -------------------------------------------------------------------------------- /tests/fixtures/signet/keyring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/signet-org/signet-python/46d60305c72d3e29466b7355f090f96ba3230c80/tests/fixtures/signet/keyring.gpg -------------------------------------------------------------------------------- /tests/fixtures/signet/repo/key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/signet-org/signet-python/46d60305c72d3e29466b7355f090f96ba3230c80/tests/fixtures/signet/repo/key -------------------------------------------------------------------------------- /tests/fixtures/test.txt: -------------------------------------------------------------------------------- 1 | hello, world! 2 | -------------------------------------------------------------------------------- /tests/fixtures/test2.txt: -------------------------------------------------------------------------------- 1 | hello, world? 2 | -------------------------------------------------------------------------------- /tests/fixtures/test_secret_keyring.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/signet-org/signet-python/46d60305c72d3e29466b7355f090f96ba3230c80/tests/fixtures/test_secret_keyring.gpg -------------------------------------------------------------------------------- /tests/fixtures/work/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/signet-org/signet-python/46d60305c72d3e29466b7355f090f96ba3230c80/tests/fixtures/work/.empty -------------------------------------------------------------------------------- /tests/test_attestation.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import hashlib 3 | import unittest 4 | 5 | import signet 6 | from tests import ( 7 | TEST_KEYID, 8 | TEST_KEYRING, 9 | TEST_SECRET_KEYRING, 10 | ) 11 | 12 | 13 | class TestAttestation(unittest.TestCase): 14 | def test_make_attestation_and_verify(self): 15 | identifier = 'sha256:' + hashlib.sha256('hello, world'.encode('ascii')).hexdigest() 16 | attestation_data = { 17 | 'id': identifier, 18 | 'comment': 'test!' 19 | } 20 | attestation = signet.make_attestation(attestation_data, TEST_KEYID, TEST_KEYRING, TEST_SECRET_KEYRING) 21 | signet.verify_attestation(attestation, TEST_KEYRING) 22 | 23 | with self.assertRaisesRegexp(signet.GPGInvalidSignatureError, 'Invalid signature'): 24 | bad_sig_attestation = copy.deepcopy(attestation) 25 | bad_sig_attestation["data"]["tampered"] = True 26 | signet.verify_attestation(bad_sig_attestation, TEST_KEYRING) 27 | 28 | with self.assertRaisesRegexp(signet.GPGInvalidSignatureError, 'Key mismatch: got \w+; expected \w+'): 29 | bad_key_attestation = copy.deepcopy(attestation) 30 | bad_key_attestation["key"] = "wrong" 31 | signet.verify_attestation(bad_key_attestation, TEST_KEYRING) 32 | -------------------------------------------------------------------------------- /tests/test_cli_integration.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | import unittest 5 | 6 | from mock import patch 7 | 8 | import signet 9 | from tests import ( 10 | TEST_KEYID, 11 | TEST_KEYRING, 12 | fixtures, 13 | ) 14 | 15 | 16 | class TestCLISetup(unittest.TestCase): 17 | def setUp(self): 18 | os.environ['GNUPGHOME'] = fixtures.path('gpg/') 19 | os.environ['SIG_DIR'] = fixtures.path('work/signet') 20 | 21 | def tearDown(self): 22 | del os.environ['GNUPGHOME'] 23 | del os.environ['SIG_DIR'] 24 | shutil.rmtree(fixtures.path('work/signet')) 25 | 26 | def test_integration(self): 27 | self.do_setup() 28 | self.do_attest_verify_ok() 29 | self.do_attest_verify_not_ok() 30 | 31 | def do_setup(self): 32 | with patch('sys.stdout') as stdout_mock: 33 | signet.SigCLI().run(['setup', TEST_KEYID[-8:]]) 34 | 35 | stdout_mock.write.assert_any_call('With public key fingerprint: 9C75CB915794A44DD7697E21571D8816D9886717\n') 36 | self.assertTrue(os.path.isfile(fixtures.path('work/signet/config'))) 37 | self.assertTrue(os.path.isfile(fixtures.path('work/signet/keyring.gpg'))) 38 | self.assertTrue(os.path.isdir(fixtures.path('work/signet/repo'))) 39 | self.assertTrue(os.path.isfile(fixtures.path('work/signet/repo/key.asc'))) 40 | self.assertTrue(os.path.isfile(fixtures.path('work/signet/repo/repo.json'))) 41 | self.assertTrue(os.path.isdir(fixtures.path('work/signet/remotes'))) 42 | 43 | with open(fixtures.path('work/signet/config')) as f: 44 | config_attestation = json.load(f) 45 | signet.verify_attestation(config_attestation, keyring=TEST_KEYRING) 46 | 47 | self.assertEqual(config_attestation['key'], TEST_KEYID) 48 | 49 | with open(fixtures.path('work/signet/repo/repo.json')) as f: 50 | repo_attestation = json.load(f) 51 | signet.verify_attestation(repo_attestation, keyring=TEST_KEYRING) 52 | 53 | self.assertEqual(repo_attestation['key'], TEST_KEYID) 54 | 55 | def do_attest_verify_ok(self): 56 | with patch('sys.stdout') as attest1_stdout_mock: 57 | with patch('__builtin__.raw_input', side_effect=['yes', 'y', 'n', 'yes', 'test']) as attest1_input_mock: 58 | signet.SigCLI().run(['attest', fixtures.path('test.txt')]) 59 | 60 | attest1_input_mock.assert_any_call('I have reviewed this file (yes/no): ') 61 | attest1_input_mock.assert_any_call('It performs as expected and is free of major flaws (yes/no): ') 62 | attest1_input_mock.assert_any_call('It performs as expected and is free of major flaws (yes/no): ') 63 | attest1_input_mock.assert_any_call('It performs as expected and is free of major flaws (yes/no): ') 64 | attest1_input_mock.assert_any_call('Comment: ') 65 | 66 | json_preview = ''.join(call[0][0] for call in attest1_stdout_mock.write.call_args_list[1:]) 67 | parsed = json.loads(json_preview) 68 | 69 | self.assertEqual(parsed, { 70 | u'comment': u'test', 71 | u'ok': True, 72 | u'id': u'sha256:4dca0fd5f424a31b03ab807cbae77eb32bf2d089eed1cee154b3afed458de0dc', 73 | u'reviewed': True, 74 | }) 75 | 76 | with patch('sys.stdout') as verify1_stdout_mock: 77 | signet.SigCLI().run(['verify', fixtures.path('test.txt')]) 78 | 79 | self.assertRegexpMatches(verify1_stdout_mock.write.call_args[0][0], 'file [\w\/]+/tests/fixtures/test.txt is \x1b\[1;32mok\x1b\[0m.\n') 80 | 81 | def do_attest_verify_not_ok(self): 82 | with patch('sys.stdout'): 83 | with patch('__builtin__.raw_input', side_effect=['yes', 'no', 'test2']): 84 | signet.SigCLI().run(['attest', fixtures.path('test2.txt')]) 85 | 86 | with self.assertRaises(SystemExit) as exit_exc: 87 | with patch('sys.stdout') as verify2_stdout_mock: 88 | signet.SigCLI().run(['verify', fixtures.path('test2.txt')]) 89 | 90 | self.assertEqual(exit_exc.exception.code, 1) 91 | self.assertRegexpMatches(verify2_stdout_mock.write.call_args[0][0], 'file [\w\/]+/tests/fixtures/test2.txt is \x1b\[1;31mmarked bad\x1b\[0m!\n') 92 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from mock import ( 3 | patch, 4 | mock_open, 5 | ) 6 | import json 7 | 8 | import signet 9 | from tests import ( 10 | fixtures, 11 | TEST_KEYID, 12 | TEST_KEYRING, 13 | TEST_SECRET_KEYRING, 14 | ) 15 | 16 | 17 | class TestConfig(unittest.TestCase): 18 | def test_missing_files(self): 19 | c = signet.Config(config_dir=fixtures.path('.')) 20 | with self.assertRaisesRegexp(signet.NoConfigError, '^Could not find'): 21 | c.save() 22 | 23 | def test_save_and_load_config(self): 24 | c = signet.Config(config_dir=fixtures.path('signet'), keyid=TEST_KEYID) 25 | c.init_defaults() 26 | c['secret_keyring'] = TEST_SECRET_KEYRING 27 | self.assertEqual(c['secret_keyring'], TEST_SECRET_KEYRING) 28 | c['test'] = True 29 | self.assertTrue(c['test']) 30 | 31 | with patch('os.path.exists', return_value=True): 32 | with patch('__builtin__.open', mock_open()) as open_write_mock: 33 | c.save() 34 | 35 | open_write_mock.assert_called_once_with(fixtures.path('signet/config'), 'w') 36 | attestation_text = ''.join(call[0][0] for call in open_write_mock.return_value.write.call_args_list) 37 | attestation = json.loads(attestation_text) 38 | 39 | self.assertEqual(attestation['data']['version'], signet.__version__) 40 | signet.verify_attestation(attestation, TEST_KEYRING) 41 | 42 | c2 = signet.Config(config_dir=fixtures.path('signet')) 43 | 44 | with patch('os.path.exists', return_value=True): 45 | with patch('__builtin__.open', mock_open(read_data=attestation_text)) as open_read_mock: 46 | c2.load() 47 | 48 | open_read_mock.assert_called_once_with(fixtures.path('signet/config')) 49 | self.assertEqual(c2.config, c.config) 50 | 51 | def test_new_config_no_key(self): 52 | c = signet.Config(config_dir=fixtures.path('signet')) 53 | with self.assertRaisesRegexp(signet.NoConfigError, 'No key specified'): 54 | c.save() 55 | -------------------------------------------------------------------------------- /tests/test_gpg.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import signet 4 | from tests import ( 5 | TEST_KEYRING, 6 | TEST_SECRET_KEYRING, 7 | TEST_KEYID, 8 | fixtures, 9 | ) 10 | 11 | 12 | TEST_DATA = 'this is a test' 13 | 14 | 15 | class TestGPG(unittest.TestCase): 16 | def test_sign_and_verify(self): 17 | signature = signet.gpg_sign( 18 | TEST_KEYID, 19 | TEST_DATA, 20 | keyring=TEST_KEYRING, 21 | secret_keyring=TEST_SECRET_KEYRING, 22 | ) 23 | 24 | signet.gpg_verify( 25 | TEST_KEYID, 26 | TEST_DATA, 27 | signature, 28 | keyring=TEST_KEYRING, 29 | ) 30 | 31 | with self.assertRaisesRegexp(signet.GPGInvalidSignatureError, 'Invalid signature'): 32 | signet.gpg_verify( 33 | TEST_KEYID, 34 | TEST_DATA, 35 | b'invalid:' + signature, 36 | keyring=TEST_KEYRING, 37 | ) 38 | 39 | with self.assertRaisesRegexp(signet.GPGInvalidSignatureError, 'Invalid signature'): 40 | signet.gpg_verify( 41 | TEST_KEYID, 42 | 'wrong:' + TEST_DATA, 43 | signature, 44 | keyring=TEST_KEYRING, 45 | ) 46 | 47 | with self.assertRaisesRegexp(signet.GPGInvalidSignatureError, 'Key mismatch: got \w+; expected \w+'): 48 | signet.gpg_verify( 49 | 'wrongkeyid', 50 | TEST_DATA, 51 | signature, 52 | keyring=TEST_KEYRING, 53 | ) 54 | 55 | with self.assertRaisesRegexp(signet.GPGKeyNotFoundError, 'Unknown key'): 56 | signet.gpg_verify( 57 | TEST_KEYID, 58 | TEST_DATA, 59 | signature, 60 | keyring=fixtures.path('empty'), 61 | ) 62 | --------------------------------------------------------------------------------