7
7
8
8
import argparse
9
9
import gi
10
+ import json
10
11
import os
11
12
import shutil
12
13
import subprocess
13
14
import sys
14
15
import tempfile
16
+ import time
15
17
16
18
import boto3
17
19
@@ -61,6 +63,8 @@ def parse_args():
61
63
group = robosig .add_mutually_exclusive_group (required = True )
62
64
group .add_argument ("--ostree" , help = "sign commit" , action = 'store_true' )
63
65
group .add_argument ("--images" , help = "sign images" , action = 'store_true' )
66
+ group .add_argument ("--oci" , metavar = 'KEY' ,
67
+ help = "sign OCI image in meta.json key (e.g. 'base-oscontainer')" )
64
68
robosig .add_argument ("--extra-fedmsg-keys" , action = 'append' ,
65
69
metavar = 'KEY=VAL' , default = [],
66
70
help = "extra keys to inject into messages" )
@@ -71,6 +75,7 @@ def parse_args():
71
75
robosig .add_argument ("--gpgkeypath" , help = "path to directory containing "
72
76
"public keys to use for signature verification" ,
73
77
default = "/etc/pki/rpm-gpg" )
78
+ robosig .add_argument ("--s3-sigstore" , help = "bucket and prefix to S3 sigstore" )
74
79
robosig .add_argument ("--verify-only" , action = 'store_true' ,
75
80
help = "verify only that the sigs are valid and make public" )
76
81
robosig .set_defaults (func = cmd_robosignatory )
@@ -106,6 +111,10 @@ def cmd_robosignatory(args):
106
111
if args .s3 is None :
107
112
raise Exception ("Missing --s3 for --ostree" )
108
113
robosign_ostree (args , s3 , build , gpgkey )
114
+ elif args .oci :
115
+ if args .verify_only :
116
+ raise Exception ("Cannot use --verify-only with --oci" )
117
+ robosign_oci (args , s3 , build , gpgkey )
109
118
else :
110
119
assert args .images
111
120
if args .s3 is None :
@@ -296,5 +305,203 @@ def validate_response(response):
296
305
assert response ['status' ].lower () == 'success' , str (response )
297
306
298
307
308
+ def robosign_oci (args , s3 , build , gpgkey ):
309
+ builds = Builds ()
310
+
311
+ # Map of {repo:tag -> digest}. "Identity" is the term used in
312
+ # containers-signature(5) to refer to how users will actually be pulling
313
+ # the image (which is usually by tag).
314
+ identities = {}
315
+ for arch in builds .get_build_arches (args .build ):
316
+ build = builds .get_build_meta (args .build , arch )
317
+ image = build .get (args .oci )
318
+ if not image :
319
+ print (f"skipping signing for missing { args .oci } image on { arch } " )
320
+ continue
321
+
322
+ # we sign for every tag we've pushed as
323
+ for tag in image ['tags' ]:
324
+ identity = f"{ image ['image' ]} :{ tag } "
325
+ identities .setdefault (identity , []).append (image ['digest' ])
326
+
327
+ # add the git commit of ourselves in the signatures for bookkeeping
328
+ creator = 'coreos-assembler'
329
+ try :
330
+ with open ('/cosa/coreos-assembler-git.json' ) as f :
331
+ cosa_git = json .load (f )
332
+ creator += ' g' + cosa_git ['git' ]['commit' ][:12 ]
333
+ except FileNotFoundError :
334
+ pass
335
+
336
+ with tempfile .TemporaryDirectory (prefix = "cosa-sign-" , dir = "tmp" ) as d :
337
+ # first, create the payloads to be signed
338
+ files_to_upload = []
339
+ for identity , digests in identities .items ():
340
+ for digest in digests :
341
+ # see https://github.com/containers/container-libs/blob/58b82c921fde7dafbc0da766f1037602cfd5553c/image/docs/containers-signature.5.md?plain=1#L110
342
+ data = {
343
+ "critical" : {
344
+ "identity" : {
345
+ "docker-reference" : identity
346
+ },
347
+ "image" : {
348
+ "docker-manifest-digest" : digest
349
+ },
350
+ "type" : "atomic container signature"
351
+ },
352
+ "optional" : {
353
+ "creator" : creator ,
354
+ "timestamp" : int (time .time ())
355
+ }
356
+ }
357
+
358
+ # Make the filename unique per identity file. This is just a
359
+ # temporary name. The final naming and structure will be different.
360
+ filename = str (abs (hash (str (data ))))
361
+ path = os .path .join (d , filename )
362
+ with open (path , 'w' ) as f :
363
+ # NB: it's important for this to be just one line so that
364
+ # we don't have to correct between how gpg canonicalizes
365
+ # the input payload differently when it's cleartext signed
366
+ # vs detached
367
+ json .dump (data , f )
368
+ files_to_upload .append ({'path' : path , 'filename' : filename ,
369
+ 'identity' : identity , 'digest' : digest })
370
+
371
+ # Upload them to S3. We upload to `staging/` first, and then will move
372
+ # them to their final location once they're verified.
373
+ sigstore_bucket , sigstore_prefix = get_bucket_and_prefix (args .s3_sigstore )
374
+ sigstore_prefix = os .path .join (sigstore_prefix , 'staging' )
375
+
376
+ # First, empty out staging/ so we don't accumulate cruft over time
377
+ # https://stackoverflow.com/a/59026702
378
+ # Note this assumes we don't run in parallel on the same sigstore
379
+ # target, which is the case for us since only one release job can run at
380
+ # a time per-stream and the S3 target location is stream-based.
381
+ staging_objects = s3 .list_objects_v2 (Bucket = sigstore_bucket , Prefix = sigstore_prefix )
382
+ objects_to_delete = [{'Key' : obj ['Key' ]} for obj in staging_objects .get ('Contents' , [])]
383
+ if len (objects_to_delete ) > 0 :
384
+ print (f'Deleting { len (objects_to_delete )} stale files' )
385
+ s3 .delete_objects (Bucket = sigstore_bucket , Delete = {'Objects' : objects_to_delete })
386
+
387
+ # now, upload the ones we want
388
+ artifacts = []
389
+ for f in files_to_upload :
390
+ s3_key = os .path .join (sigstore_prefix , f ['filename' ])
391
+ print (f"Uploading s3://{ sigstore_bucket } /{ s3_key } " )
392
+ s3 .upload_file (f ['path' ], sigstore_bucket , s3_key )
393
+ artifacts .append ({
394
+ 'file' : f"s3://{ sigstore_bucket } /{ s3_key } " ,
395
+ 'checksum' : f"sha256:{ sha256sum_file (f ['path' ])} "
396
+ })
397
+
398
+ response = send_request_and_wait_for_response (
399
+ request_type = 'artifacts-sign' ,
400
+ config = args .fedmsg_conf ,
401
+ request_timeout = ROBOSIGNATORY_REQUEST_TIMEOUT_SEC ,
402
+ priority = ROBOSIGNATORY_MESSAGE_PRIORITY ,
403
+ environment = fedenv ,
404
+ body = {
405
+ 'build_id' : args .build ,
406
+ # We pass a 'basearch' here but we're actually bulk signing
407
+ # for all arches in one shot. But we can't omit it because
408
+ # Robosignatory logs it. It's not used otherwise.
409
+ 'basearch' : args .arch ,
410
+ 'artifacts' : artifacts ,
411
+ ** args .extra_keys
412
+ }
413
+ )
414
+
415
+ validate_response (response )
416
+
417
+ # download sigs, verify, finalize, and upload to final location
418
+ def gpg (* args ):
419
+ subprocess .check_call (['gpg' , '--homedir' , d , * args ])
420
+
421
+ gpg ('--quiet' , '--import' , gpgkey )
422
+
423
+ sig_counter = {}
424
+ # peel off the '/staging' bit
425
+ final_sigstore_prefix = os .path .dirname (sigstore_prefix )
426
+ for f in files_to_upload :
427
+ stg_s3_key = os .path .join (sigstore_prefix , f ['filename' ])
428
+ stg_sig_s3_key = stg_s3_key + '.sig'
429
+
430
+ tmp_sig_path = os .path .join (d , f ['filename' ] + '.sig' )
431
+ print (f"Downloading s3://{ sigstore_bucket } /{ stg_sig_s3_key } " )
432
+ s3 .download_file (sigstore_bucket , stg_sig_s3_key , tmp_sig_path )
433
+ s3 .delete_object (Bucket = sigstore_bucket , Key = stg_s3_key )
434
+ s3 .delete_object (Bucket = sigstore_bucket , Key = stg_sig_s3_key )
435
+
436
+ local_artifact = f ['path' ]
437
+
438
+ print (f"Verifying detached signature for { local_artifact } " )
439
+ try :
440
+ gpg ('--verify' , tmp_sig_path , local_artifact )
441
+ except subprocess .CalledProcessError as e :
442
+ # allow unknown signatures in stg
443
+ if fedenv != 'stg' :
444
+ raise e
445
+
446
+ # This is where the magic happens, from a detached signature, we
447
+ # merge it with the original payload to create a cleartext signed
448
+ # message so it's a single artifact like c/image expects.
449
+ # See also: https://github.com/containers/container-libs/pull/307
450
+ with open (tmp_sig_path , 'rb' ) as fp :
451
+ armored_sig = subprocess .check_output (['gpg' , '--homedir' , d , '--enarmor' ], input = fp .read ())
452
+ armored_sig = str (armored_sig , encoding = 'utf-8' )
453
+
454
+ # not strictly required, but looks more like a usual cleartext signature
455
+ armored_sig = armored_sig .replace ('ARMORED FILE' , 'SIGNATURE' )
456
+
457
+ with open (local_artifact , 'r' ) as fp :
458
+ original_content = fp .read ()
459
+
460
+ signed_message = "-----BEGIN PGP SIGNED MESSAGE-----\n "
461
+ # Right now, we assume Robosignatory (really Sigul), uses SHA256;
462
+ # in theory we could parse the signature and get the digest algo
463
+ # that was used, but it seems unlikely that Sigul will change this
464
+ # before it's sunset, at which pont we would've already moved on
465
+ # from this code. If it does, here's one way to do it: call `gpg
466
+ # --list-packets` and look for 'digest algo N' and convert N to the
467
+ # right string based on
468
+ # https://github.com/gpg/gnupg/blob/6771ed4c13226ea8f410d022fa83888930070f70/common/openpgpdefs.h#L185
469
+ signed_message += "Hash: SHA256\n \n "
470
+ signed_message += original_content + "\n "
471
+ signed_message += armored_sig
472
+
473
+ # just overwrite the original payload; we don't need it anymore
474
+ with open (f ['path' ], 'w' ) as fp :
475
+ fp .write (signed_message )
476
+
477
+ print (f"Verifying cleartext signature { f ['path' ]} " )
478
+ try :
479
+ gpg ('--verify' , f ['path' ])
480
+ except subprocess .CalledProcessError as e :
481
+ # allow unknown signatures in stg
482
+ if fedenv != 'stg' :
483
+ raise e
484
+
485
+ # tell c/image that it's a valid signature
486
+ # https://github.com/containers/container-libs/blob/58b82c921fde7dafbc0da766f1037602cfd5553c/image/internal/signature/signature.go#L66
487
+ signed_message = b'\x00 simple-signing\n ' + bytes (signed_message , encoding = 'utf-8' )
488
+ with open (f ['path' ], 'wb' ) as fp :
489
+ fp .write (signed_message )
490
+
491
+ image_repo = f ['identity' ]
492
+ # e.g. "quay.io/fedora/fedora-coreos:stable" -> "fedora/fedora-coreos"
493
+ _ , image_repo = image_repo .split ('/' , 1 )
494
+ image_repo , _ = image_repo .split (':' )
495
+
496
+ sig_prefix = f"{ image_repo } @{ f ['digest' ].replace (':' , '=' )} "
497
+ sig_number = sig_counter .get (sig_prefix , 0 ) + 1
498
+ sig_counter [sig_prefix ] = sig_number
499
+
500
+ # upload to final location and make public
501
+ final_s3_key = os .path .join (final_sigstore_prefix , sig_prefix , f"signature-{ sig_number } " )
502
+ print (f"Uploading { f ['path' ]} to s3://{ sigstore_bucket } /{ final_s3_key } " )
503
+ s3 .upload_file (f ['path' ], sigstore_bucket , final_s3_key , ExtraArgs = {'ACL' : 'public-read' })
504
+
505
+
299
506
if __name__ == '__main__' :
300
507
sys .exit (main ())
0 commit comments