diff --git a/endpoints/api/signing.py b/endpoints/api/signing.py index 2758a999d..aa426ff7c 100644 --- a/endpoints/api/signing.py +++ b/endpoints/api/signing.py @@ -27,9 +27,4 @@ class RepositorySignatures(RepositoryParamResource): if repo is None or not repo.trust_enabled: raise NotFound() - tag_data, expiration = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository) - return { - 'tags': tag_data, - 'expiration': expiration - } - + return {'delegations': tuf_metadata_api.get_all_tags_with_expiration(namespace, repository)} diff --git a/endpoints/api/test/test_signing.py b/endpoints/api/test/test_signing.py index a0320d015..31f37d632 100644 --- a/endpoints/api/test/test_signing.py +++ b/endpoints/api/test/test_signing.py @@ -8,37 +8,47 @@ from endpoints.api.signing import RepositorySignatures from test.fixtures import * -VALID_TARGETS = { - 'latest': { - 'hashes': { - 'sha256': 'mLmxwTyUrqIRDaz8uaBapfrp3GPERfsDg2kiMujlteo=' - }, - 'length': 1500 - }, - 'test_tag': { - 'hashes': { - 'sha256': '1234123' - }, - 'length': 50 +VALID_TARGETS_MAP = { + "targets/ci": { + "targets": { + "latest": { + "hashes": { + "sha256": "2Q8GLEgX62VBWeL76axFuDj/Z1dd6Zhx0ZDM6kNwPkQ=" + }, + "length": 2111 + } + }, + "expiration": "2020-05-22T10:26:46.618176424-04:00" + }, + "targets": { + "targets": { + "latest": { + "hashes": { + "sha256": "2Q8GLEgX62VBWeL76axFuDj/Z1dd6Zhx0ZDM6kNwPkQ=" + }, + "length": 2111 + } + }, + "expiration": "2020-05-22T10:26:01.953414888-04:00"} } -} + def tags_equal(expected, actual): - expected_tags = expected.get('tags') - actual_tags = actual.get('tags') + expected_tags = expected.get('delegations') + actual_tags = actual.get('delegations') if expected_tags and actual_tags: return Counter(expected_tags) == Counter(actual_tags) return expected == actual -@pytest.mark.parametrize('targets,expected', [ - (VALID_TARGETS, {'tags': VALID_TARGETS, 'expiration': 'expires'}), - ({'bad': 'tags'}, {'tags': {'bad': 'tags'}, 'expiration': 'expires'}), - ({}, {'tags': {}, 'expiration': 'expires'}), - (None, {'tags': None, 'expiration': 'expires'}), # API returns None on exceptions +@pytest.mark.parametrize('targets_map,expected', [ + (VALID_TARGETS_MAP, {'delegations': VALID_TARGETS_MAP}), + ({'bad': 'tags'}, {'delegations': {'bad': 'tags'}}), + ({}, {'delegations': {}}), + (None, {'delegations': None}), # API returns None on exceptions ]) -def test_get_signatures(targets, expected, client): +def test_get_signatures(targets_map, expected, client): with patch('endpoints.api.signing.tuf_metadata_api') as mock_tuf: - mock_tuf.get_default_tags_with_expiration.return_value = (targets, 'expires') + mock_tuf.get_all_tags_with_expiration.return_value = targets_map with client_with_identity('devtable', client) as cl: params = {'repository': 'devtable/trusted'} assert tags_equal(expected, conduct_api_call(cl, RepositorySignatures, 'GET', params, None, 200).json) diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 7e03596bf..c5ca2543a 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -43,7 +43,7 @@ .repo-panel-tags-element .image-track-line.start { top: 18px; - height: 25px; + height: 28px; display: block; } @@ -144,6 +144,10 @@ padding-top: 0px; } +.repo-panel-tags-element .signing-delegations-list { + margin-top: 8px; +} + @media (max-width: 1000px) { .repo-panel-tags-element .image-track { display: none; diff --git a/static/css/directives/ui/manifest-label-list.css b/static/css/directives/ui/manifest-label-list.css index 840f1b737..8cf247d42 100644 --- a/static/css/directives/ui/manifest-label-list.css +++ b/static/css/directives/ui/manifest-label-list.css @@ -1,5 +1,17 @@ .manifest-label-list-element { padding-left: 6px; + display: inline-block; + position: relative; +} + +.manifest-label-list-element:before { + content: "\f02c"; + font-family: FontAwesome; + position: absolute; + left: -22px; + top: 0px; + font-size: 15px; + color: #888; } .manifest-label-list-element .none { diff --git a/static/css/directives/ui/tag-signing-display.css b/static/css/directives/ui/tag-signing-display.css index d05d4608b..e450fcbb2 100644 --- a/static/css/directives/ui/tag-signing-display.css +++ b/static/css/directives/ui/tag-signing-display.css @@ -2,10 +2,11 @@ text-align: center; display: inline-block; cursor: default; + position: relative; } .tag-signing-display-element .fa { - font-size: 18px; + font-size: 24px; } .tag-signing-display-element .fa.fa-question-circle { @@ -22,34 +23,153 @@ color: #9B9B9B; } -.tag-signing-display-element .signing-valid .okay, -.tag-signing-display-element .signing-valid .expires-soon { +.tag-signing-display-element .signing-valid.okay-release { color: #2FC98E; } - -.tag-signing-display-element .signing-valid .expires-soon { - position: relative; +.tag-signing-display-element .signing-valid.okay { + color: #5f9dd0; } -.tag-signing-display-element .signing-valid .expires-soon:after { - border-radius: 50%; - width: 6px; - height: 6px; - position: absolute; - bottom: 0px; - right: 0px; - z-index: 1; - display: inline-block; - content: " "; - background-color: #FCA657; -} - - -.tag-signing-display-element .signing-valid .expired { +.tag-signing-display-element .signing-valid.partial-okay { color: #FCA657; } .tag-signing-display-element .signing-invalid { color: #D64456; -} \ No newline at end of file +} + +.tag-signing-display-element .indicator { + position: relative; +} + +.tag-signing-display-element .expiring-soon { + border-radius: 100%; + background-color: #fbab62; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 2; +} + +.tag-signing-display-element .expired { + border-radius: 100%; + background-color: #ec5266; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 3; +} + +.tag-signing-display-element .invalid { + border-radius: 100%; + background-color: #ec5266; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 4; +} + +.tag-signing-display-element.extended { + display: block; + position: relative; +} + +.tag-signing-display-element.extended .fa { + color: #888 !important; + font-size: 16px; +} + +.tag-signing-display-element.extended .indicator { + font-size: 16px; + position: absolute; + left: -22px; + top: 4px; +} + +.tag-signing-display-element.extended .delegations { + margin: 0px; + padding: 0px; + text-align: left; + padding-left: 6px; + padding-top: 6px; +} + +.tag-signing-display-element.extended .delegations td { + padding: 4px; + border: 0px; +} + +.tag-signing-display-element.extended .delegations .delegation { + padding: 4px; + background-color: #eee; + border-radius: 4px; + font-size: 13px; + padding-right: 6px; + display: inline-block; +} + +.tag-signing-display-element.extended .delegations .delegation:before { + content: "\f00c"; + font-family: FontAwesome; + margin-right: 2px; + margin-left: 2px; + display: inline-block; + font-size: 10px; +} + +.tag-signing-display-element.extended .delegations .delegation.okay { + background-color: #d0deea; +} + +.tag-signing-display-element.extended .delegations .delegation.okay:before { + color: #5f9dd0; +} + +.tag-signing-display-element.extended .delegations .delegation.default { + background-color: #bdf1dd; +} + +.tag-signing-display-element.extended .delegations .delegation.default:before { + color: #2FC98E; +} + +.tag-signing-display-element.extended .delegations .delegation.warning { + background-color: #ffe0c4; +} + +.tag-signing-display-element.extended .delegations .delegation.warning:before { + content: "\f12a"; + color: #FCA657; +} + +.tag-signing-display-element.extended .delegations .delegation.error { + background-color: #ffcad1; +} + +.tag-signing-display-element.extended .delegations .delegation.error:before { + content: "\f00d"; + color: #D64456; +} + +.tag-signing-display-element.extended .delegations .delegation-name { + font-size: 14px; +} + +.tag-signing-display-element.extended .delegations .delegation-info { + display: inline-block; + white-space: nowrap; + vertical-align: middle; + margin-left: 4px; + font-size: 12px; +} + diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 154dcfd97..a3b1d3e5a 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -131,7 +131,7 @@ - + @@ -261,9 +261,15 @@ - + +
+ + +
+ +
diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index bcd4c6a15..55d544575 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -39,7 +39,7 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.labelCache = {}; $scope.imageVulnerabilities = {}; - $scope.repoSignatureInfo = null; + $scope.repoDelegationsInfo = null; $scope.defcon1 = {}; $scope.hasDefcon1 = false; @@ -50,16 +50,16 @@ angular.module('quay').directive('repoPanelTags', function () { } $scope.repoSignatureError = false; - $scope.repoSignatureInfo = null; + $scope.repoDelegationsInfo = null; var params = { 'repository': $scope.repository.namespace + '/' + $scope.repository.name }; ApiService.getRepoSignatures(null, params).then(function(resp) { - $scope.repoSignatureInfo = resp; + $scope.repoDelegationsInfo = resp; }, function() { - $scope.repoSignatureInfo = {'error': true}; + $scope.repoDelegationsInfo = {'error': true}; }); }; diff --git a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html index be57ff502..fc257dc4b 100644 --- a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html +++ b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html @@ -1,5 +1,5 @@ - - + + @@ -17,33 +17,81 @@ - - - - - - - + + + - - - - - - - - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ This tag has not been signed +
+ + + + + +
+ + (Default) + {{ delegation.delegationName.substr('targets/'.length) }} + +
+ + Expiring soon: {{ delegation.expiration.format("YYYY-MM-DD HH:mm:ss") }} + + Signature expired + + Signature has different hash: {{ delegation.delegationHash.substr(0, 12) }} + +
+
\ No newline at end of file diff --git a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts index 67a7bc02d..f6852e407 100644 --- a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts +++ b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts @@ -1,7 +1,27 @@ import { Input, Component, Inject } from 'ng-metadata/core'; -import { ApostilleSignatureDocument, ApostilleTagDocument } from '../../../types/common.types'; +import { ApostilleDelegationsSet, ApostilleSignatureDocument, ApostilleTagDocument } from '../../../types/common.types'; import * as moment from "moment"; +type TagSigningInfo = { + delegations: DelegationInfo[]; + delegationsByName: {[delegationName: string]: DelegationInfo}; + + hasExpiringSoon: boolean; + hasExpired: boolean; + hasInvalid: boolean; +} + +type DelegationInfo = { + delegationName: string; + delegationHash: string; + expiration: moment.Moment; + hasMatchingHash: boolean; + isExpired: boolean; + isExpiringSoon: boolean +}; + +var RELEASES = ['targets/releases', 'targets']; + /** * A component that displays the signing status of a tag in the repository view. */ @@ -10,17 +30,22 @@ import * as moment from "moment"; templateUrl: '/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html', }) export class TagSigningDisplayComponent { + @Input('<') public compact: boolean; @Input('<') public tag: any; - @Input('<') public signatures: ApostilleSignatureDocument; + @Input('<') public delegations: ApostilleDelegationsSet; - private signedDigest: string; - private pushedDigest: string; + private cachedSigningInfo: TagSigningInfo | null = null; constructor(@Inject("$sanitize") private $sanitize: ng.sanitize.ISanitizeService) {} private base64ToHex(base64String: string): string { // Based on: http://stackoverflow.com/questions/39460182/decode-base64-to-hexadecimal-string-with-javascript - var raw = atob(base64String); + try { + var raw = atob(base64String); + } catch (e) { + return '(invalid)'; + } + var hexString = ''; for (var i = 0; i < raw.length; ++i) { var char = raw.charCodeAt(i); @@ -30,49 +55,122 @@ export class TagSigningDisplayComponent { return hexString; } - private expirationStatus(tag: any, signatures: ApostilleSignatureDocument): string { - if (!signatures || !signatures.expiration) { - return 'unknown'; - } + private buildDelegationInfo(tag: any, delegationName: string, delegation: ApostilleSignatureDocument): DelegationInfo { + var digest_without_prefix = tag.manifest_digest.substr('sha256:'.length); + var hex_signature = this.base64ToHex(delegation.targets[tag.name].hashes['sha256']); - var expires = moment(signatures.expiration); + var expires = moment(delegation.expiration); var now = moment(); - - if (expires.isSameOrBefore(now)) { - return 'expired'; - } - var withOneWeek = moment().add('1', 'w'); - if (expires.isSameOrBefore(withOneWeek)) { - return 'expires-soon'; - } - return 'okay'; + return { + 'delegationName': delegationName, + 'hasMatchingHash': digest_without_prefix == hex_signature, + 'expiration': expires, + 'delegationHash': hex_signature, + 'isExpired': expires.isSameOrBefore(now), + 'isExpiringSoon': !expires.isSameOrBefore(now) && expires.isSameOrBefore(withOneWeek), + } } - private signingStatus(tag: any, signatures: ApostilleSignatureDocument): string { - if (!tag || !signatures) { + private buildTagSigningInfo(tag: any, delegationSet: ApostilleDelegationsSet): TagSigningInfo { + var info = { + 'delegations': [], + 'delegationsByName': {}, + 'hasExpired': false, + 'hasExpiringSoon': false, + 'hasInvalid': false, + } + + // Find all delegations containing the tag as a target. + Object.keys(delegationSet.delegations).forEach((delegationName) => { + var delegation = delegationSet.delegations[delegationName]; + if (delegation.targets[tag.name]) { + var DelegationInfo = this.buildDelegationInfo(tag, delegationName, delegation) + info.delegations.push(DelegationInfo); + info.delegationsByName[delegationName] = DelegationInfo; + + if (DelegationInfo.isExpired) { + info.hasExpired = true; + } + + if (DelegationInfo.isExpiringSoon) { + info.hasExpiringSoon = true; + } + + if (!DelegationInfo.hasMatchingHash) { + info.hasInvalid = true; + } + } + }); + + return info; + } + + private isDefaultDelegation(name: string): boolean { + return RELEASES.indexOf(name) >= 0; + } + + private getSigningInfo(tag: any, delegationSet: ApostilleDelegationsSet): TagSigningInfo { + if (!this.cachedSigningInfo) { + this.cachedSigningInfo = this.buildTagSigningInfo(tag, delegationSet); + } + return this.cachedSigningInfo; + } + + private signingStatus(tag: any, delegationSet: ApostilleDelegationsSet): string { + if (!tag || !delegationSet) { return 'loading'; } - if (signatures.error || !signatures.tags) { - return 'error'; - } - - var tag_info = signatures.tags[tag.name]; - if (!tag_info || !tag.manifest_digest) { + if (!tag.manifest_digest) { return 'not-signed'; } - var digest_without_prefix = tag.manifest_digest.substr('sha256:'.length); - var hex_signature = this.base64ToHex(tag_info.hashes['sha256']); - - if (hex_signature == digest_without_prefix) { - return 'valid-signature'; - } else { - this.signedDigest = this.$sanitize(hex_signature); - this.pushedDigest = this.$sanitize(digest_without_prefix); - return 'invaid-signature'; + if (delegationSet.error) { + return 'error'; } + + // Check if signed at all. + var signingInfo = this.getSigningInfo(tag, delegationSet); + if (!signingInfo.delegations.length) { + return 'not-signed'; + } + + // Check if all delegations are signed and valid. + var allReleasesValid = true; + var oneReleaseValid = false; + + this.cachedSigningInfo.delegations.forEach(function(delegation) { + var isValid = delegation.hasMatchingHash && !delegation.isExpired; + if (isValid) { + oneReleaseValid = true; + } + + allReleasesValid = allReleasesValid && isValid; + }); + + // Check if the special RELEASES target(s) is/are signed and valid. + var releasesDelegation = null; + RELEASES.forEach((releaseTarget) => { + var delegation = this.cachedSigningInfo.delegationsByName[releaseTarget]; + if (delegation && !releasesDelegation) { + releasesDelegation = delegation; + } + }); + + if (releasesDelegation && releasesDelegation.hasMatchingHash && !releasesDelegation.isExpired) { + if (allReleasesValid && this.cachedSigningInfo.delegations.length > 1) { + return 'all-signed'; + } else { + return 'release-signed'; + } + } + + if (allReleasesValid || oneReleaseValid) { + return 'non-release-signed'; + } + + return 'invalid-signed'; } } \ No newline at end of file diff --git a/static/js/types/common.types.ts b/static/js/types/common.types.ts index 042785e9e..088867010 100644 --- a/static/js/types/common.types.ts +++ b/static/js/types/common.types.ts @@ -122,6 +122,16 @@ export type TriggerConfig = { }; +/** + * Represents a set of apostille delegations. + */ +export type ApostilleDelegationsSet = { + delegations: {[delegationName: string]: ApostilleSignatureDocument}; + + // The error that occurred, if any. + error: string | null; +}; + /** * Represents an apostille signature document, with extra expiration information. */ @@ -130,10 +140,7 @@ export type ApostilleSignatureDocument = { expiration: string // Object of information for each tag. - tags: {string: ApostilleTagDocument} - - // If true, an error occurred while trying to load this document. - error: boolean + targets: {string: ApostilleTagDocument} }; diff --git a/util/tufmetadata/api.py b/util/tufmetadata/api.py index 25d066634..410509cef 100644 --- a/util/tufmetadata/api.py +++ b/util/tufmetadata/api.py @@ -11,7 +11,7 @@ from data.database import CloseForLongOperation from util.abchelpers import nooper from util.failover import failover, FailoverException from util.security.instancekeys import InstanceKeys -from util.security.registry_jwt import build_context_and_subject, generate_bearer_token, QUAY_TUF_ROOT +from util.security.registry_jwt import build_context_and_subject, generate_bearer_token, QUAY_TUF_ROOT, SIGNER_TUF_ROOT DEFAULT_HTTP_HEADERS = {'Connection': 'close'} @@ -150,16 +150,21 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface): if not targets_file: targets_file = 'targets.json' + + targets_name = targets_file + if targets_name.endswith('.json'): + targets_name = targets_name[:-5] if not targets_map: targets_map = {} signed = self._get_signed(namespace, repository, targets_file) if not signed: - return None + targets_map[targets_name] = None + return targets_map if signed.get('targets'): - targets_map[targets_file] = { + targets_map[targets_name] = { 'targets': signed.get('targets'), 'expiration': signed.get('expires'), } @@ -167,7 +172,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface): delegation_names = [role.get('name') for role in signed.get('delegations').get('roles')] for delegation in delegation_names: - targets_map = self.get_all_tags_with_expiration(namespace, repository, targets_file=delegation, targets_map=targets_map) + targets_map = self.get_all_tags_with_expiration(namespace, repository, targets_file=delegation + '.json', targets_map=targets_map) return targets_map @@ -235,7 +240,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface): 'name': gun, 'actions': actions, }] - context, subject = build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=QUAY_TUF_ROOT) + context, subject = build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=SIGNER_TUF_ROOT) token = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access, TOKEN_VALIDITY_LIFETIME_S, self._instance_keys) return {'Authorization': 'Bearer %s' % token} diff --git a/util/tufmetadata/test/test_tufmetadata.py b/util/tufmetadata/test/test_tufmetadata.py index 8f73290e4..27255c795 100644 --- a/util/tufmetadata/test/test_tufmetadata.py +++ b/util/tufmetadata/test/test_tufmetadata.py @@ -177,7 +177,7 @@ def test_get_default_tags(response_code, response_body, expected): (200, valid_targets_with_delegation, valid_delegation, { 'targets/devs': { 'targets': valid_delegation['signed']['targets'], 'expiration': valid_delegation['signed']['expires']}}), - (200, {'garbage': 'data'}, {'garbage': 'data'}, None) + (200, {'garbage': 'data'}, {'garbage': 'data'}, {'targets': None}) ]) def test_get_all_tags(response_code, response_body1, response_body2, expected): app = Flask(__name__)