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) }}
+
+
+ |
+
+
+
+ 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__)
| |