github.com/vchain-us/vcn@v0.9.11-0.20210921212052-a2484d23c0b3/pkg/cmd/verify/verify.go (about) 1 /* 2 * Copyright (c) 2018-2020 vChain, Inc. All Rights Reserved. 3 * This software is released under GPL3. 4 * The full license information can be found under: 5 * https://www.gnu.org/licenses/gpl-3.0.en.html 6 * 7 */ 8 9 package verify 10 11 import ( 12 "context" 13 "encoding/json" 14 "fmt" 15 "regexp" 16 "strconv" 17 "strings" 18 19 "github.com/blang/semver" 20 "github.com/fatih/color" 21 "github.com/spf13/cobra" 22 "github.com/spf13/viper" 23 "google.golang.org/grpc/metadata" 24 25 immuschema "github.com/codenotary/immudb/pkg/api/schema" 26 "github.com/vchain-us/ledger-compliance-go/schema" 27 vcnerr "github.com/vchain-us/vcn/internal/errors" 28 "github.com/vchain-us/vcn/pkg/api" 29 "github.com/vchain-us/vcn/pkg/bom/artifact" 30 "github.com/vchain-us/vcn/pkg/cmd/internal/cli" 31 "github.com/vchain-us/vcn/pkg/cmd/internal/types" 32 "github.com/vchain-us/vcn/pkg/extractor" 33 "github.com/vchain-us/vcn/pkg/meta" 34 "github.com/vchain-us/vcn/pkg/signature" 35 "github.com/vchain-us/vcn/pkg/store" 36 ) 37 38 type pkg struct { 39 Name string 40 Hash string 41 Kind string 42 Md md `json:"metadata"` 43 Status int 44 } 45 46 type md struct { 47 Version string `json:"version,omitempty"` 48 HashType string `json:"hashType"` 49 } 50 51 var ( 52 keyRegExp = regexp.MustCompile("0x[0-9a-z]{40}") 53 ) 54 55 func getSignerIDs() []string { 56 ids := viper.GetStringSlice("signerID") 57 if len(ids) > 0 { 58 return ids 59 } 60 return viper.GetStringSlice("key") 61 } 62 63 // NewCommand returns the cobra command for `vcn verify` 64 func NewCommand() *cobra.Command { 65 cmd := &cobra.Command{ 66 Use: "authenticate", 67 Example: " vcn authenticate /bin/vcn", 68 Aliases: []string{"a", "verify", "v"}, 69 Short: "Authenticate assets against the blockchain", 70 Long: ` 71 Authenticate assets against the blockchain. 72 73 Authentication is the process of matching the hash of a local asset to 74 a hash on the blockchain. 75 If matched, the returned result (the authentication) is the blockchain-stored 76 metadata that’s bound to the matching hash. 77 Otherwise, the returned result status equals UNKNOWN. 78 79 Note that your assets will not be uploaded but processed locally. 80 81 The exit code will be 0 only if all assets' statuses are equal to TRUSTED. 82 Otherwise, the exit code will be 1. 83 84 Assets are referenced by the passed ARG(s), with authentication accepting 85 1 or more ARG(s) at a time. Multiple assets can be authenticated at the 86 same time while passing them within ARG(s). 87 88 ARG must be one of: 89 <file> 90 file://<file> 91 dir://<directory> 92 git://<repository> 93 docker://<image> 94 podman://<image> 95 javacom://<java mvn jar or pom.xml> 96 nodecom://<node component> 97 gocom://<Go module in name@version format> 98 pythoncom://<Python module in name@version format> 99 dotnetcom://<.Net module in name@version format> 100 Environment variables: 101 VCN_USER= 102 VCN_PASSWORD= 103 VCN_NOTARIZATION_PASSWORD= 104 VCN_NOTARIZATION_PASSWORD_EMPTY= 105 VCN_OTP= 106 VCN_OTP_EMPTY= 107 VCN_LC_HOST= 108 VCN_LC_PORT= 109 VCN_LC_CERT= 110 VCN_LC_SKIP_TLS_VERIFY=false 111 VCN_LC_NO_TLS=false 112 VCN_LC_API_KEY= 113 VCN_LC_LEDGER= 114 VCN_SIGNING_PUB_KEY_FILE= 115 VCN_SIGNING_PUB_KEY= 116 VCN_ENFORCE_SIGNATURE_VERIFY= 117 `, 118 RunE: runVerify, 119 PreRun: func(cmd *cobra.Command, args []string) { 120 // Bind to all flags to env vars (after flags were parsed), 121 // but only ones retrivied by using viper will be used. 122 viper.BindPFlags(cmd.Flags()) 123 }, 124 Args: func(cmd *cobra.Command, args []string) error { 125 if org := viper.GetString("org"); org != "" { 126 if keys := getSignerIDs(); len(keys) > 0 { 127 return fmt.Errorf("cannot use both --org and SignerID(s)") 128 } 129 } 130 131 alerts, _ := cmd.Flags().GetBool("alerts") 132 if alerts { 133 if len(args) > 0 { 134 return fmt.Errorf("cannot use ARG(s) with --alerts") 135 } 136 return nil 137 } 138 139 if hash, _ := cmd.Flags().GetString("hash"); hash != "" { 140 if len(args) > 0 { 141 return fmt.Errorf("cannot use ARG(s) with --hash") 142 } 143 if alerts { 144 return fmt.Errorf("cannot use both --alerts and --hash") 145 } 146 147 return nil 148 } 149 150 if name, _ := cmd.Flags().GetString("name"); name != "" { 151 if len(args) > 0 { 152 return fmt.Errorf("cannot use ARG(s) with --name") 153 } 154 return nil 155 } 156 157 return cobra.MinimumNArgs(1)(cmd, args) 158 }, 159 } 160 161 cmd.SetUsageTemplate( 162 strings.Replace(cmd.UsageTemplate(), "{{.UseLine}}", "{{.UseLine}} ARG(s)", 1), 163 ) 164 165 cmd.Flags().StringSliceP("signerID", "s", nil, "accept only authentications matching the passed SignerID(s)\n(overrides VCN_SIGNERID env var, if any). It's valid both for blockchain and ledger compliance") 166 cmd.Flags().StringSliceP("key", "k", nil, "") 167 cmd.Flags().MarkDeprecated("key", "please use --signerID instead") 168 cmd.Flags().StringP("org", "I", "", "accept only authentications matching the passed organisation's ID,\nif set no SignerID can be used\n(overrides VCN_ORG env var, if any)") 169 cmd.Flags().String("hash", "", "specify a hash to authenticate, if set no ARG(s) can be used") 170 cmd.Flags().Bool("alerts", false, "specify to authenticate and monitor for the configured alerts, if set no ARG(s) can be used") 171 cmd.Flags().Bool("raw-diff", false, "print raw a diff, if any") 172 cmd.Flags().Int("exit-code", meta.VcnDefaultExitCode, meta.VcnExitCode) 173 cmd.Flags().String("lc-host", "", meta.VcnLcHostFlagDesc) 174 cmd.Flags().String("lc-port", "443", meta.VcnLcPortFlagDesc) 175 cmd.Flags().String("lc-cert", "", meta.VcnLcCertPathDesc) 176 cmd.Flags().Bool("lc-skip-tls-verify", false, meta.VcnLcSkipTlsVerifyDesc) 177 cmd.Flags().Bool("lc-no-tls", false, meta.VcnLcNoTlsDesc) 178 cmd.Flags().String("lc-api-key", "", meta.VcnLcApiKeyDesc) 179 cmd.Flags().String("lc-ledger", "", meta.VcnLcLedgerDesc) 180 cmd.Flags().String("lc-uid", "", meta.VcnLcUidDesc) 181 cmd.Flags().String("attach", "", meta.VcnLcAttachmentAuthDesc) 182 cmd.Flags().Bool("force", false, meta.VcnLcForceAttachmentDownloadDesc) 183 cmd.Flags().String("name", "", "asset name to look up") 184 cmd.Flags().String("version", "", "asset version to look up") 185 cmd.Flags().Bool("bom", false, "link asset to its dependencies from BoM") 186 cmd.Flags().String("bom-trust-level", "trusted", "min trust level: untrusted (unt) / unsupported (uns) / unknown (unk) / trusted (t)") 187 cmd.Flags().Float64("bom-max-unsupported", 0, "max number (in %) of unsupported dependencies") 188 cmd.Flags().String("bom-file", "", "store BoM in the file for later processing") 189 cmd.Flags().Bool("bom-deps-only", false, "authenticate only the dependencies, not the asset") 190 cmd.Flags().Bool("bom-what-includes", false, "output all assets that use the specified asset") 191 cmd.Flags().StringSlice("bom-container-binary", []string{}, "list of binaries to be executed inside the container - only the relevant dependencies will be processed") 192 cmd.Flags().Uint("bom-batch-size", 10, "By default BoM dependencies are authenticated/notarized in batches of up to 10 dependencies each. Use this flag to set a different batch size. A value of 0 will disable batching (all dependencies will be authenticated/notarized at once).") 193 // BoM output options 194 cmd.Flags().Bool("bom-debug", false, "show extra debug info for BoM processing, also disable progress indicators") 195 cmd.Flags().String("bom-spdx", "", "name of the file to output BoM in SPDX format") 196 cmd.Flags().String("bom-cyclonedx-json", "", "name of the file to output BoM in CycloneDX JSON format") 197 cmd.Flags().String("bom-cyclonedx-xml", "", "name of the file to output BoM in CycloneDX XML format") 198 cmd.Flags().String("github-token", "", "Github OAuth token for querying BoM Github package details. Either authenticated or not, requests are subject to Github limits") 199 200 cmd.Flags().String("signing-pub-key-file", "", meta.VcnSigningPubKeyFileNameDesc) 201 cmd.Flags().String("signing-pub-key", "", meta.VcnSigningPubKeyDesc) 202 cmd.Flags().Bool("enforce-signature-verify", false, meta.VcnEnforceSignatureVerifyDesc) 203 cmd.Flags().MarkHidden("raw-diff") 204 205 return cmd 206 } 207 208 // runVerify first determine if the context is LC or blockchain, then call the correct verify 209 func runVerify(cmd *cobra.Command, args []string) error { 210 hashes := make([]string, 0) 211 hash, err := cmd.Flags().GetString("hash") 212 if err != nil { 213 return err 214 } 215 if hash != "" { 216 hashes = append(hashes, strings.ToLower(hash)) 217 } 218 219 output, err := cmd.Flags().GetString("output") 220 if err != nil { 221 return err 222 } 223 224 useAlerts, err := cmd.Flags().GetBool("alerts") 225 if err != nil { 226 return err 227 } 228 229 cmd.SilenceUsage = true 230 231 lcHost := viper.GetString("lc-host") 232 lcPort := viper.GetString("lc-port") 233 lcCert := viper.GetString("lc-cert") 234 skipTlsVerify := viper.GetBool("lc-skip-tls-verify") 235 noTls := viper.GetBool("lc-no-tls") 236 lcApiKey := viper.GetString("lc-api-key") 237 lcLedger := viper.GetString("lc-ledger") 238 lcUid := viper.GetString("lc-uid") 239 lcAttach := viper.GetString("attach") 240 lcAttachForce := viper.GetBool("force") 241 lcVerbose := viper.GetBool("verbose") 242 243 signingPubKey, skipLocalPubKeyComp, err := signature.PrepareSignatureParams( 244 viper.GetString("signing-pub-key"), 245 viper.GetString("signing-pub-key-file")) 246 if err != nil { 247 return err 248 } 249 enforceSignatureVerify := viper.GetBool("enforce-signature-verify") 250 251 //check if an lcUser is present inside the context 252 var lcUser *api.LcUser 253 254 uif, err := api.GetUserFromContext(store.Config().CurrentContext, lcApiKey, lcLedger, signingPubKey) 255 if err != nil { 256 return err 257 } 258 if lctmp, ok := uif.(*api.LcUser); ok { 259 lcUser = lctmp 260 } 261 262 // It uses flags for CNC context client constructor if at least host is provided. A client with empty api-key is allowed for public authentications 263 if lcHost != "" { 264 // client from context could be override by the one created from local flags 265 lcUser, err = api.NewLcUser(lcApiKey, lcLedger, lcHost, lcPort, lcCert, skipTlsVerify, noTls, signingPubKey) 266 if err != nil { 267 return err 268 } 269 // Store the new config 270 if lcApiKey != "" { 271 if err := store.SaveConfig(); err != nil { 272 return err 273 } 274 } 275 } 276 277 if lcUser != nil { 278 var signerID string 279 signerIDs := getSignerIDs() 280 if len(signerIDs) > 0 { 281 signerID = signerIDs[0] 282 } 283 if lcApiKey == "" && signerID == "" { 284 return vcnerr.ErrPubAuthNoSignerID 285 } 286 287 err = lcUser.Client.Connect() 288 if err != nil { 289 return err 290 } 291 292 if !skipLocalPubKeyComp { 293 err = lcUser.CheckConnectionPublicKey(enforceSignatureVerify) 294 if err != nil { 295 return err 296 } 297 } 298 299 name := viper.GetString("name") 300 if name != "" { 301 // asset selection by name with optional filtering by version 302 if hash != "" { 303 return fmt.Errorf("cannot specify both the assets name/version and hash") 304 } 305 version := viper.GetString("version") 306 md := metadata.Pairs(meta.VcnLCPluginTypeHeaderName, meta.VcnLCPluginTypeHeaderValue) 307 ctx := metadata.NewOutgoingContext(context.Background(), md) 308 if signerID == "" { 309 signerID = api.GetSignerIDByApiKey(lcUser.Client.ApiKey) 310 } 311 312 zItems, err := lcUser.Client.ZScan(ctx, &immuschema.ZScanRequest{ 313 Set: []byte(name), 314 NoWait: true, 315 }) 316 if err != nil { 317 return fmt.Errorf("cannot get components by name: %w", err) 318 } 319 var vRange semver.Range 320 if version != "" { 321 vRange, err = semver.ParseRange(version) 322 if err != nil { 323 return fmt.Errorf("cannot parse version range expression: %w", err) 324 } 325 } 326 for _, item := range zItems.Entries { 327 var p pkg 328 err := json.Unmarshal(item.Entry.Value, &p) 329 if err != nil { 330 return fmt.Errorf("cannot parse JSON: %w", err) 331 } 332 if version != "" { 333 if p.Md.Version != "" { 334 ver := strings.TrimPrefix(p.Md.Version, "v") 335 v, err := semver.Parse(ver) 336 if err != nil { 337 fmt.Printf("asset has invalid version %s\n", p.Md.Version) 338 continue 339 } 340 if vRange(v) { 341 hashes = append(hashes, p.Hash) 342 } 343 } 344 } else { 345 // no version condition - add all 346 hashes = append(hashes, p.Hash) 347 } 348 } 349 if len(hashes) == 0 { 350 return fmt.Errorf("no assets matching specified name/version found") 351 } 352 } 353 354 // any set 'bom-xxx' option, except 'bom-what-includes', implies BoM 355 bomFlag := viper.GetBool("bom") || 356 viper.IsSet("bom-file") || 357 viper.IsSet("bom-deps-only") || 358 viper.IsSet("bom-debug") || 359 viper.IsSet("bom-trust-level") || 360 viper.IsSet("bom-max-unsupported") || 361 viper.IsSet("bom-spdx") || 362 viper.IsSet("bom-cyclonedx-json") || 363 viper.IsSet("bom-cyclonedx-xml") || 364 viper.IsSet("bom-container-binary") || 365 viper.IsSet("bom-batch-size") 366 367 if bomFlag { 368 err := lcUser.RequireFeatOrErr(schema.FeatBoM) 369 if err != nil { 370 return err 371 } 372 } 373 374 var bomArtifact artifact.Artifact 375 if bomFlag { 376 if len(hashes)+len(args) > 1 { 377 return fmt.Errorf("asset selection criteria match several assets - BoM can be processed only for single asset") 378 } 379 if len(hashes)+len(args) < 1 { 380 return fmt.Errorf("asset selection criteria don't match any assets - BoM cannot be processed") 381 } 382 383 if len(hashes) > 0 { 384 bomArtifact, err = processBOM(lcUser, signerID, output, hashes[0], "") 385 } else { 386 bomArtifact, err = processBOM(lcUser, signerID, output, "", args[0]) 387 } 388 if err != nil { 389 return err 390 } 391 } 392 393 if !viper.GetBool("bom-deps-only") { 394 // by hash 395 if len(hashes) > 0 { 396 for _, hash := range hashes { 397 a := &api.Artifact{ 398 Hash: hash, 399 } 400 if viper.GetBool("bom-what-includes") { 401 a.IncludedIn, err = GetIncluded(hash, signerID, lcUser) 402 if err != nil { 403 return err 404 } 405 } 406 err = lcVerify(cmd, a, lcUser, signerID, lcUid, lcAttach, lcAttachForce, lcVerbose, output) 407 if err != nil { 408 return err 409 } 410 } 411 } else { 412 artifacts, err := extractor.Extract([]string{args[0]}) 413 if err != nil { 414 return err 415 } 416 for _, a := range artifacts { 417 if viper.GetBool("bom-what-includes") { 418 a.IncludedIn, err = GetIncluded(a.Hash, signerID, lcUser) 419 if err != nil { 420 return err 421 } 422 } 423 if bomArtifact != nil { 424 a.Deps = DepsToPackageDetails(bomArtifact.Dependencies()) 425 } 426 err := lcVerify(cmd, a, lcUser, signerID, lcUid, lcAttach, lcAttachForce, lcVerbose, output) 427 if err != nil { 428 return err 429 } 430 } 431 } 432 } 433 434 return nil 435 } 436 437 if output == "attachments" { 438 return fmt.Errorf("in order to download attachments, you need to be logged in on Codenotary Cloud®\nProceed by authenticating yourself using <vcn login>") 439 } 440 // blockchain context 441 org := viper.GetString("org") 442 var keys []string 443 if org != "" { 444 bo, err := api.GetBlockChainOrganisation(org) 445 if err != nil { 446 return err 447 } 448 keys = bo.MembersIDs() 449 } else { 450 keys = getSignerIDs() 451 // add 0x if missing, lower case, and check if format is correct 452 for i, k := range keys { 453 if !strings.HasPrefix(k, "0x") { 454 keys[i] = "0x" + k 455 } 456 keys[i] = strings.ToLower(keys[i]) 457 if !keyRegExp.MatchString(keys[i]) { 458 return fmt.Errorf("invalid public address format: %s", k) 459 } 460 } 461 } 462 463 user := api.NewUser(store.Config().CurrentContext.Email) 464 465 // by alerts 466 if useAlerts { 467 if hasAuth, _ := user.IsAuthenticated(); !hasAuth { 468 return fmt.Errorf("in order to use --alerts, you need to be logged in\nProceed by authenticating yourself using <vcn login>") 469 } 470 471 alertConfigPath, err := store.AlertFilepath(user.Email()) 472 if err != nil { 473 return err 474 } 475 if output == "" { 476 fmt.Printf("Using alert configuration: %s\n\n", alertConfigPath) 477 } 478 479 alerts, err := store.ReadAlerts(user.Email()) 480 if err != nil { 481 return err 482 } 483 484 if len(alerts) == 0 { 485 return fmt.Errorf("no configured alerts") 486 } 487 488 for _, alert := range alerts { 489 var alertConfig api.AlertConfig 490 if err := alert.ExportConfig(&alertConfig); err != nil { 491 cli.PrintWarning(output, fmt.Sprintf( 492 `invalid alert config (name="%s") for %s: %s`, 493 alert.Name, 494 alert.Arg, 495 err, 496 )) 497 continue 498 } 499 alertConfig.Metadata["arg"] = alert.Arg 500 501 artifacts, err := extractor.Extract([]string{alert.Arg}) 502 if err != nil { 503 cli.PrintWarning(output, err.Error()) 504 alertConfig.Metadata["error"] = err.Error() 505 user.TriggerAlert(alertConfig) 506 continue 507 } 508 if artifacts == nil { 509 cli.PrintWarning(output, fmt.Sprintf("unable to process the input asset provided: %s", alert.Arg)) 510 alertConfig.Metadata["error"] = err.Error() 511 user.TriggerAlert(alertConfig) 512 continue 513 } 514 for _, a := range artifacts { 515 if err := verify(cmd, a, keys, org, user, &alertConfig, output); err != nil { 516 cli.PrintWarning(output, fmt.Sprintf("%s: %s", alert.Arg, err)) 517 } 518 if output == "" { 519 fmt.Println() 520 } 521 } 522 } 523 return nil 524 } 525 526 // by hash 527 if hash != "" { 528 a := &api.Artifact{ 529 Hash: strings.ToLower(hash), 530 } 531 if err := verify(cmd, a, keys, org, user, nil, output); err != nil { 532 return err 533 } 534 return nil 535 } 536 537 // by args 538 for _, arg := range args { 539 artifacts, err := extractor.Extract([]string{arg}) 540 if err != nil { 541 return err 542 } 543 if artifacts == nil { 544 return fmt.Errorf("unable to process the input asset provided: %s", arg) 545 } 546 for _, a := range artifacts { 547 if err := verify(cmd, a, keys, org, user, nil, output); err != nil { 548 return err 549 } 550 } 551 } 552 553 return nil 554 } 555 556 func verify(cmd *cobra.Command, a *api.Artifact, keys []string, org string, user *api.User, alertConfig *api.AlertConfig, output string) (err error) { 557 hook := newHook(cmd, a) 558 var verification *api.BlockchainVerification 559 if output == "" { 560 fmt.Println() 561 color.Set(meta.StyleAffordance()) 562 fmt.Println("Your assets will not be uploaded. They will be processed locally.") 563 color.Unset() 564 fmt.Println() 565 } 566 // if keys have been passed, check for a verification matching them 567 if len(keys) > 0 { 568 if output == "" { 569 if org == "" { 570 fmt.Printf("Looking for blockchain entry matching the passed SignerIDs...\n") 571 } else { 572 fmt.Printf("Looking for blockchain entry matching the organization (%s)...\n", org) 573 } 574 } 575 verification, err = api.VerifyMatchingSignerIDs(a.Hash, keys) 576 577 } else { 578 // if we have an user, check for verification matching user's key first 579 userKey := "" 580 if hasAuth, _ := user.IsAuthenticated(); hasAuth { 581 userKey, _ = user.SignerID() // todo(leogr): double check this 582 } 583 if userKey != "" { 584 if output == "" { 585 fmt.Printf("Looking for blockchain entry matching the current user (%s)...\n", user.Email()) 586 } 587 verification, err = api.VerifyMatchingSignerIDWithFallback(a.Hash, userKey) 588 if output == "" { 589 if verification.SignerID() != userKey { 590 fmt.Printf("No blockchain entry matching the current user found.\n") 591 if !verification.Unknown() { 592 fmt.Printf("Showing the last blockchain entry with highest level available.\n") 593 } 594 } 595 } 596 } else { 597 // if no passed keys nor user, 598 // just get the last with highest level available verification 599 if output == "" { 600 fmt.Printf("Looking for the last blockchain entry with highest level available...\n") 601 } 602 verification, err = api.Verify(a.Hash) 603 } 604 } 605 606 if output == "" { 607 fmt.Println() 608 } 609 610 if err != nil { 611 return fmt.Errorf("unable to authenticate the hash: %s", err) 612 } 613 614 err = hook.finalize(alertConfig, output) 615 if err != nil { 616 return err 617 } 618 619 var ar *api.ArtifactResponse 620 if !verification.Unknown() { 621 ar, _ = api.LoadArtifact(user, a.Hash, verification.MetaHash()) 622 } 623 624 if err = cli.Print(output, types.NewResult(a, ar, verification)); err != nil { 625 return err 626 } 627 628 if output != "" { 629 cmd.SilenceErrors = true 630 } 631 632 // todo(ameingast/leogr): remove reduntat event - need backend improvement 633 if verification.Trusted() { 634 api.TrackVerify(user, a.Hash, a.Name) 635 } 636 637 if alertConfig != nil { 638 var err error 639 if verification.Trusted() { 640 err = user.PingAlert(*alertConfig) 641 } else { 642 err = user.TriggerAlert(*alertConfig) 643 } 644 if err != nil { 645 return err 646 } 647 648 if output == "" { 649 fmt.Printf("\nPing for alert %s sent.\n", alertConfig.AlertUUID) 650 } 651 api.TrackPublisher(user, meta.VcnAlertVerifyEvent) 652 } else { 653 api.TrackPublisher(user, meta.VcnVerifyEvent) 654 } 655 656 if !verification.Trusted() { 657 errLabels := map[meta.Status]string{ 658 meta.StatusUnknown: "was not notarized", 659 meta.StatusUntrusted: "is untrusted", 660 meta.StatusUnsupported: "is unsupported", 661 } 662 663 viper.Set("exit-code", strconv.Itoa(verification.Status.Int())) 664 665 switch true { 666 case org != "": 667 return fmt.Errorf(`%s %s by "%s"`, a.Hash, errLabels[verification.Status], org) 668 case len(keys) == 1: 669 return fmt.Errorf("%s %s by %s", a.Hash, errLabels[verification.Status], keys[0]) 670 case len(keys) > 1: 671 return fmt.Errorf("%s %s by any of %s", a.Hash, errLabels[verification.Status], strings.Join(keys, ", ")) 672 default: 673 return fmt.Errorf("%s %s", a.Hash, errLabels[verification.Status]) 674 } 675 } 676 677 return 678 }