porting all github actions from bluesky-social/indigo to tangled CI

Goat PLC identity management commands (#1120)

Adds the following new subcommands to `goat plc`, for PLC identity
management without the involvement of a PDS:

```
genesis produce an unsigned genesis operation
calc-did calculate the DID corresponding to a signed PLC operation
sign sign an operation, ready to be submitted
submit submit a signed operation to the PLC directory
update apply updates to a previous operation produce a new one (but don't sign or submit it, yet)
```

authored by David Buchanan and committed by GitHub eebba3d9 d178d99b

Changed files
+446
cmd
goat
+441
cmd/goat/plc.go
··· 6 "fmt" 7 "io" 8 "net/http" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/identity" 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 "github.com/bluesky-social/indigo/util" 15 16 "github.com/urfave/cli/v2" 17 ) ··· 70 }, 71 }, 72 Action: runPLCDump, 73 }, 74 }, 75 } ··· 320 } 321 return &d, nil 322 }
··· 6 "fmt" 7 "io" 8 "net/http" 9 + "net/url" 10 "strings" 11 "time" 12 13 + "github.com/bluesky-social/indigo/atproto/crypto" 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 "github.com/bluesky-social/indigo/util" 17 + 18 + "github.com/did-method-plc/go-didplc" 19 20 "github.com/urfave/cli/v2" 21 ) ··· 74 }, 75 }, 76 Action: runPLCDump, 77 + }, 78 + &cli.Command{ 79 + Name: "genesis", 80 + Usage: "produce an unsigned genesis operation", 81 + Flags: []cli.Flag{ 82 + &cli.StringFlag{ 83 + Name: "handle", 84 + Usage: "atproto handle", 85 + }, 86 + &cli.StringSliceFlag{ 87 + Name: "rotation-key", 88 + Usage: "rotation public key, in did:key format", 89 + }, 90 + &cli.StringFlag{ 91 + Name: "atproto-key", 92 + Usage: "atproto repo signing public key, in did:key format", 93 + }, 94 + &cli.StringFlag{ 95 + Name: "pds", 96 + Usage: "atproto PDS service URL", 97 + }, 98 + }, 99 + Action: runPLCGenesis, 100 + }, 101 + &cli.Command{ 102 + Name: "calc-did", 103 + Usage: "calculate the DID corresponding to a signed PLC operation", 104 + ArgsUsage: `<signed_genesis.json>`, 105 + Flags: []cli.Flag{}, 106 + Action: runPLCCalcDID, 107 + }, 108 + &cli.Command{ 109 + Name: "sign", 110 + Usage: "sign an operation, ready to be submitted", 111 + ArgsUsage: `<operation.json>`, 112 + Flags: []cli.Flag{ 113 + &cli.StringFlag{ 114 + Name: "plc-signing-key", 115 + Usage: "private key used to sign operation (multibase syntax)", 116 + EnvVars: []string{"PLC_SIGNING_KEY"}, 117 + }, 118 + }, 119 + Action: runPLCSign, 120 + }, 121 + &cli.Command{ 122 + Name: "submit", 123 + Usage: "submit a signed operation to the PLC directory", 124 + ArgsUsage: `<signed_operation.json>`, 125 + Flags: []cli.Flag{ 126 + &cli.BoolFlag{ 127 + Name: "genesis", 128 + Usage: "the operation is a genesis operation", 129 + }, 130 + &cli.StringFlag{ 131 + Name: "did", 132 + Usage: "the DID of the identity to update", 133 + }, 134 + }, 135 + Action: runPLCSubmit, 136 + }, 137 + &cli.Command{ 138 + Name: "update", 139 + Usage: "apply updates to a previous operation to produce a new one (but don't sign or submit it, yet)", 140 + ArgsUsage: `<DID>`, 141 + Flags: []cli.Flag{ 142 + &cli.StringFlag{ 143 + Name: "prev", 144 + Usage: "the CID of the operation to use as a base (uses most recent op if not specified)", 145 + }, 146 + &cli.StringFlag{ 147 + Name: "handle", 148 + Usage: "atproto handle", 149 + }, 150 + &cli.StringSliceFlag{ 151 + Name: "add-rotation-key", 152 + Usage: "rotation public key, in did:key format (added to front of rotationKey list)", 153 + }, 154 + &cli.StringSliceFlag{ 155 + Name: "remove-rotation-key", 156 + Usage: "rotation public key, in did:key format", 157 + }, 158 + &cli.StringFlag{ 159 + Name: "atproto-key", 160 + Usage: "atproto repo signing public key, in did:key format", 161 + }, 162 + &cli.StringFlag{ 163 + Name: "pds", 164 + Usage: "atproto PDS service URL", 165 + }, 166 + }, 167 + Action: runPLCUpdate, 168 }, 169 }, 170 } ··· 415 } 416 return &d, nil 417 } 418 + 419 + func runPLCGenesis(cctx *cli.Context) error { 420 + // TODO: helper function in didplc to make an empty op like this? 421 + services := make(map[string]didplc.OpService) 422 + verifMethods := make(map[string]string) 423 + op := didplc.RegularOp{ 424 + Type: "plc_operation", 425 + RotationKeys: []string{}, 426 + VerificationMethods: verifMethods, 427 + AlsoKnownAs: []string{}, 428 + Services: services, 429 + } 430 + 431 + for _, rotationKey := range cctx.StringSlice("rotation-key") { 432 + if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 433 + return err 434 + } 435 + op.RotationKeys = append(op.RotationKeys, rotationKey) 436 + } 437 + 438 + handle := cctx.String("handle") 439 + if handle != "" { 440 + parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://")) 441 + if err != nil { 442 + return err 443 + } 444 + parsedHandle = parsedHandle.Normalize() 445 + op.AlsoKnownAs = append(op.AlsoKnownAs, "at://"+string(parsedHandle)) 446 + } 447 + 448 + atprotoKey := cctx.String("atproto-key") 449 + if atprotoKey != "" { 450 + if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil { 451 + return err 452 + } 453 + op.VerificationMethods["atproto"] = atprotoKey 454 + } 455 + 456 + pds := cctx.String("pds") 457 + if pds != "" { 458 + parsedUrl, err := url.Parse(pds) 459 + if err != nil { 460 + return err 461 + } 462 + if !parsedUrl.IsAbs() { 463 + return fmt.Errorf("invalid PDS URL: must be absolute") 464 + } 465 + op.Services["atproto_pds"] = didplc.OpService{ 466 + Type: "AtprotoPersonalDataServer", 467 + Endpoint: pds, 468 + } 469 + } 470 + 471 + res, err := json.MarshalIndent(op, "", " ") 472 + if err != nil { 473 + return err 474 + } 475 + fmt.Println(string(res)) 476 + 477 + return nil 478 + } 479 + 480 + func runPLCCalcDID(cctx *cli.Context) error { 481 + s := cctx.Args().First() 482 + if s == "" { 483 + return fmt.Errorf("need to provide genesis json path as input") 484 + } 485 + 486 + inputReader, err := getFileOrStdin(s) 487 + if err != nil { 488 + return err 489 + } 490 + 491 + inBytes, err := io.ReadAll(inputReader) 492 + if err != nil { 493 + return err 494 + } 495 + 496 + var enum didplc.OpEnum 497 + if err := json.Unmarshal(inBytes, &enum); err != nil { 498 + return err 499 + } 500 + op := enum.AsOperation() 501 + 502 + did, err := op.DID() // errors if op is not a signed genesis op 503 + if err != nil { 504 + return err 505 + } 506 + 507 + fmt.Println(did) 508 + 509 + return nil 510 + } 511 + 512 + func runPLCSign(cctx *cli.Context) error { 513 + s := cctx.Args().First() 514 + if s == "" { 515 + return fmt.Errorf("need to provide PLC operation json path as input") 516 + } 517 + 518 + privStr := cctx.String("plc-signing-key") 519 + if privStr == "" { 520 + return fmt.Errorf("private key must be provided") 521 + } 522 + 523 + inputReader, err := getFileOrStdin(s) 524 + if err != nil { 525 + return err 526 + } 527 + 528 + inBytes, err := io.ReadAll(inputReader) 529 + if err != nil { 530 + return err 531 + } 532 + 533 + var enum didplc.OpEnum 534 + if err := json.Unmarshal(inBytes, &enum); err != nil { 535 + return err 536 + } 537 + op := enum.AsOperation() 538 + 539 + // Note: we do not require that the op is currently unsigned. 540 + // If it's already signed, we'll re-sign it. 541 + 542 + privkey, err := crypto.ParsePrivateMultibase(privStr) 543 + if err != nil { 544 + return err 545 + } 546 + 547 + if err := op.Sign(privkey); err != nil { 548 + return err 549 + } 550 + 551 + res, err := json.MarshalIndent(op, "", " ") 552 + if err != nil { 553 + return err 554 + } 555 + fmt.Println(string(res)) 556 + 557 + return nil 558 + } 559 + 560 + func runPLCSubmit(cctx *cli.Context) error { 561 + ctx := context.Background() 562 + expectGenesis := cctx.Bool("genesis") 563 + didString := cctx.String("did") 564 + 565 + if !expectGenesis && didString == "" { 566 + return fmt.Errorf("exactly one of either --genesis or --did must be specified") 567 + } 568 + 569 + if expectGenesis && didString != "" { 570 + return fmt.Errorf("exactly one of either --genesis or --did must be specified") 571 + } 572 + 573 + s := cctx.Args().First() 574 + if s == "" { 575 + return fmt.Errorf("need to provide PLC operation json path as input") 576 + } 577 + 578 + inputReader, err := getFileOrStdin(s) 579 + if err != nil { 580 + return err 581 + } 582 + 583 + inBytes, err := io.ReadAll(inputReader) 584 + if err != nil { 585 + return err 586 + } 587 + 588 + var enum didplc.OpEnum 589 + if err := json.Unmarshal(inBytes, &enum); err != nil { 590 + return err 591 + } 592 + op := enum.AsOperation() 593 + 594 + if op.IsGenesis() != expectGenesis { 595 + if expectGenesis { 596 + return fmt.Errorf("expected genesis operation, but a non-genesis operation was provided") 597 + } else { 598 + return fmt.Errorf("expected non-genesis operation, but a genesis operation was provided") 599 + } 600 + } 601 + 602 + if op.IsGenesis() { 603 + didString, err = op.DID() 604 + if err != nil { 605 + return err 606 + } 607 + } 608 + 609 + if !op.IsSigned() { 610 + return fmt.Errorf("operation must be signed") 611 + } 612 + 613 + c := didplc.Client{ 614 + DirectoryURL: cctx.String("plc-host"), 615 + UserAgent: *userAgent(), 616 + } 617 + 618 + if err = c.Submit(ctx, didString, op); err != nil { 619 + return err 620 + } 621 + 622 + fmt.Println("success") 623 + 624 + return nil 625 + } 626 + 627 + // fetch logs from /log/audit, select according to base_cid ("" means use latest), and 628 + // prepare it for updates: 629 + // - convert from legacy op format if needed (and reject tombstone ops) 630 + // - strip signature 631 + // - set `prev` to appropriate value 632 + func fetchOpForUpdate(ctx context.Context, c didplc.Client, did string, base_cid string) (*didplc.RegularOp, error) { 633 + auditlog, err := c.AuditLog(ctx, did) 634 + if err != nil { 635 + return nil, err 636 + } 637 + 638 + if err = didplc.VerifyOpLog(auditlog); err != nil { 639 + return nil, err 640 + } 641 + 642 + var baseLogEntry *didplc.LogEntry 643 + if base_cid == "" { 644 + // use most recent entry 645 + baseLogEntry = &auditlog[len(auditlog)-1] 646 + } else { 647 + // scan for the specified entry 648 + for _, entry := range auditlog { 649 + if entry.CID == base_cid { 650 + baseLogEntry = &entry 651 + break 652 + } 653 + } 654 + if baseLogEntry == nil { 655 + return nil, fmt.Errorf("no operation found matching CID %s", base_cid) 656 + } 657 + } 658 + var op didplc.RegularOp 659 + switch baseOp := baseLogEntry.Operation.AsOperation().(type) { 660 + case *didplc.RegularOp: 661 + op = *baseOp 662 + op.Sig = nil 663 + case *didplc.LegacyOp: 664 + op = baseOp.RegularOp() // also strips sig 665 + case *didplc.TombstoneOp: 666 + return nil, fmt.Errorf("cannot update from a tombstone op") 667 + } 668 + op.Prev = &baseLogEntry.CID 669 + return &op, nil 670 + } 671 + 672 + func runPLCUpdate(cctx *cli.Context) error { 673 + ctx := context.Background() 674 + prevCID := cctx.String("prev") 675 + 676 + didString := cctx.Args().First() 677 + if didString == "" { 678 + return fmt.Errorf("please specify a DID to update") 679 + } 680 + 681 + c := didplc.Client{ 682 + DirectoryURL: cctx.String("plc-host"), 683 + UserAgent: *userAgent(), 684 + } 685 + op, err := fetchOpForUpdate(ctx, c, didString, prevCID) 686 + if err != nil { 687 + return err 688 + } 689 + 690 + for _, rotationKey := range cctx.StringSlice("remove-rotation-key") { 691 + if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 692 + return err 693 + } 694 + removeSuccess := false 695 + for idx, existingRotationKey := range op.RotationKeys { 696 + if existingRotationKey == rotationKey { 697 + op.RotationKeys = append(op.RotationKeys[:idx], op.RotationKeys[idx+1:]...) 698 + removeSuccess = true 699 + } 700 + } 701 + if !removeSuccess { 702 + return fmt.Errorf("failed remove rotation key %s, not found in array", rotationKey) 703 + } 704 + } 705 + 706 + for _, rotationKey := range cctx.StringSlice("add-rotation-key") { 707 + if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil { 708 + return err 709 + } 710 + // prepend (Note: if adding multiple rotation keys at once, they'll end up in reverse order) 711 + op.RotationKeys = append([]string{rotationKey}, op.RotationKeys...) 712 + } 713 + 714 + handle := cctx.String("handle") 715 + if handle != "" { 716 + parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://")) 717 + if err != nil { 718 + return err 719 + } 720 + 721 + // strip any existing at:// akas 722 + // (someone might have some non-atproto akas, we will leave them untouched, 723 + // they can manually manage those or use some other tool if needed) 724 + var akas []string 725 + for _, aka := range op.AlsoKnownAs { 726 + if !strings.HasPrefix(aka, "at://") { 727 + akas = append(akas, aka) 728 + } 729 + } 730 + op.AlsoKnownAs = append(akas, "at://"+string(parsedHandle)) 731 + } 732 + 733 + atprotoKey := cctx.String("atproto-key") 734 + if atprotoKey != "" { 735 + if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil { 736 + return err 737 + } 738 + op.VerificationMethods["atproto"] = atprotoKey 739 + } 740 + 741 + pds := cctx.String("pds") 742 + if pds != "" { 743 + parsedUrl, err := url.Parse(pds) 744 + if err != nil { 745 + return err 746 + } 747 + if !parsedUrl.IsAbs() { 748 + return fmt.Errorf("invalid PDS URL: must be absolute") 749 + } 750 + op.Services["atproto_pds"] = didplc.OpService{ 751 + Type: "AtprotoPersonalDataServer", 752 + Endpoint: pds, 753 + } 754 + } 755 + 756 + res, err := json.MarshalIndent(op, "", " ") 757 + if err != nil { 758 + return err 759 + } 760 + fmt.Println(string(res)) 761 + 762 + return nil 763 + }
+1
go.mod
··· 88 github.com/cockroachdb/redact v1.1.5 // indirect 89 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect 90 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 91 github.com/getsentry/sentry-go v0.27.0 // indirect 92 github.com/go-redis/redis v6.15.9+incompatible // indirect 93 github.com/goccy/go-json v0.10.2 // indirect
··· 88 github.com/cockroachdb/redact v1.1.5 // indirect 89 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect 90 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 91 + github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 // indirect 92 github.com/getsentry/sentry-go v0.27.0 // indirect 93 github.com/go-redis/redis v6.15.9+incompatible // indirect 94 github.com/goccy/go-json v0.10.2 // indirect
+4
go.sum
··· 81 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 82 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 83 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 84 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE= 85 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= 86 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
··· 81 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 82 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 83 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 84 + github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797 h1:yYj4PNkUnWSh0Fhsl/pUoxMvBVaVeY6ZebkWMyGzW9k= 85 + github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 86 + github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88= 87 + github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM= 88 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE= 89 github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= 90 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=