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

goat: basic account PLC key commands (#959)

eg:

```sh
goat key generate

goat account login -u $HANDLE -p $PASSWORD

goat account plc current

goat account plc request-token

goat account plc add-rotation-key --token $PLCTOKEN $PUBKEY

goat account plc current
```

authored by bnewbold.net and committed by GitHub b5b2806e 24b790dc

Changed files
+301 -43
cmd
+142 -1
cmd/goat/account_plc.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "os" 8 + "slices" 8 9 9 10 "github.com/bluesky-social/indigo/api/agnostic" 10 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/crypto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 11 14 12 15 "github.com/urfave/cli/v2" 13 16 ) ··· 15 18 var cmdAccountPlc = &cli.Command{ 16 19 Name: "plc", 17 20 Usage: "sub-commands for managing PLC DID via PDS host", 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "plc-host", 24 + Usage: "method, hostname, and port of PLC registry", 25 + Value: "https://plc.directory", 26 + EnvVars: []string{"ATP_PLC_HOST"}, 27 + }, 28 + }, 18 29 Subcommands: []*cli.Command{ 19 30 &cli.Command{ 20 31 Name: "recommended", ··· 34 45 Flags: []cli.Flag{ 35 46 &cli.StringFlag{ 36 47 Name: "token", 37 - Usage: "2FA token for signing request", 48 + Usage: "2FA token for PLC operation signing request", 38 49 }, 39 50 }, 40 51 }, ··· 43 54 Usage: "submit a PLC operation (via PDS)", 44 55 ArgsUsage: `<json-file>`, 45 56 Action: runAccountPlcSubmit, 57 + }, 58 + &cli.Command{ 59 + Name: "current", 60 + Usage: "print current PLC data for account (fetched from directory)", 61 + Action: runAccountPlcCurrent, 62 + }, 63 + &cli.Command{ 64 + Name: "add-rotation-key", 65 + Usage: "add a new rotation key to PLC identity (via PDS)", 66 + ArgsUsage: `<pubkey>`, 67 + Action: runAccountPlcAddRotationKey, 68 + Flags: []cli.Flag{ 69 + &cli.StringFlag{ 70 + Name: "token", 71 + Usage: "2FA token for PLC operation signing request", 72 + }, 73 + &cli.BoolFlag{ 74 + Name: "first", 75 + Usage: "inserts key at the top of key list (highest priority)", 76 + }, 77 + }, 46 78 }, 47 79 }, 48 80 } ··· 168 200 169 201 return nil 170 202 } 203 + 204 + func runAccountPlcCurrent(cctx *cli.Context) error { 205 + ctx := context.Background() 206 + 207 + xrpcc, err := loadAuthClient(ctx) 208 + if err == ErrNoAuthSession || xrpcc.Auth == nil { 209 + return fmt.Errorf("auth required, but not logged in") 210 + } else if err != nil { 211 + return err 212 + } 213 + 214 + did, err := syntax.ParseDID(xrpcc.Auth.Did) 215 + if err != nil { 216 + return err 217 + } 218 + 219 + plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did) 220 + if err != nil { 221 + return err 222 + } 223 + 224 + b, err := json.MarshalIndent(plcData, "", " ") 225 + if err != nil { 226 + return err 227 + } 228 + fmt.Println(string(b)) 229 + return nil 230 + } 231 + 232 + func runAccountPlcAddRotationKey(cctx *cli.Context) error { 233 + ctx := context.Background() 234 + 235 + newKeyStr := cctx.Args().First() 236 + if newKeyStr == "" { 237 + return fmt.Errorf("need to provide public key argument (as did:key)") 238 + } 239 + 240 + // check that it is a valid pubkey 241 + _, err := crypto.ParsePublicDIDKey(newKeyStr) 242 + if err != nil { 243 + return err 244 + } 245 + 246 + xrpcc, err := loadAuthClient(ctx) 247 + if err == ErrNoAuthSession { 248 + return fmt.Errorf("auth required, but not logged in") 249 + } else if err != nil { 250 + return err 251 + } 252 + 253 + did, err := syntax.ParseDID(xrpcc.Auth.Did) 254 + if err != nil { 255 + return err 256 + } 257 + 258 + // 1. fetch current PLC op: plc.directory/{did}/data 259 + plcData, err := fetchPLCData(ctx, cctx.String("plc-host"), did) 260 + if err != nil { 261 + return err 262 + } 263 + 264 + if len(plcData.RotationKeys) >= 5 { 265 + fmt.Println("WARNGING: already have 5 rotation keys, which is the maximum") 266 + } 267 + 268 + for _, k := range plcData.RotationKeys { 269 + if k == newKeyStr { 270 + return fmt.Errorf("key already registered as a rotation key") 271 + } 272 + } 273 + 274 + // 2. update data 275 + if cctx.Bool("first") { 276 + plcData.RotationKeys = slices.Insert(plcData.RotationKeys, 0, newKeyStr) 277 + } else { 278 + plcData.RotationKeys = append(plcData.RotationKeys, newKeyStr) 279 + } 280 + 281 + // 3. get data signed (using token) 282 + opBytes, err := json.Marshal(&plcData) 283 + if err != nil { 284 + return err 285 + } 286 + var body agnostic.IdentitySignPlcOperation_Input 287 + if err = json.Unmarshal(opBytes, &body); err != nil { 288 + return fmt.Errorf("failed decoding PLC op JSON: %w", err) 289 + } 290 + 291 + token := cctx.String("token") 292 + if token != "" { 293 + body.Token = &token 294 + } 295 + 296 + resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body) 297 + if err != nil { 298 + return err 299 + } 300 + 301 + // 4. submit signed op 302 + err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{ 303 + Operation: resp.Operation, 304 + }) 305 + if err != nil { 306 + return fmt.Errorf("failed submitting PLC op via PDS: %w", err) 307 + } 308 + 309 + fmt.Println("Success!") 310 + return nil 311 + }
+32 -12
cmd/goat/crypto.go cmd/goat/key.go
··· 8 8 "github.com/urfave/cli/v2" 9 9 ) 10 10 11 - var cmdCrypto = &cli.Command{ 12 - Name: "crypto", 11 + var cmdKey = &cli.Command{ 12 + Name: "key", 13 13 Usage: "sub-commands for cryptographic keys", 14 14 Subcommands: []*cli.Command{ 15 15 &cli.Command{ ··· 21 21 Aliases: []string{"t"}, 22 22 Usage: "indicate curve type (P-256 is default)", 23 23 }, 24 + &cli.BoolFlag{ 25 + Name: "terse", 26 + Usage: "print just the secret key, in multikey format", 27 + }, 24 28 }, 25 - Action: runCryptoGenerate, 29 + Action: runKeyGenerate, 26 30 }, 27 31 &cli.Command{ 28 - Name: "inspect", 29 - Usage: "parses and outputs metadata about a public or secret key", 30 - Action: runCryptoInspect, 32 + Name: "inspect", 33 + Usage: "parses and outputs metadata about a public or secret key", 34 + ArgsUsage: `<key>`, 35 + Action: runKeyInspect, 31 36 }, 32 37 }, 33 38 } 34 39 35 - func runCryptoGenerate(cctx *cli.Context) error { 40 + func runKeyGenerate(cctx *cli.Context) error { 41 + var priv crypto.PrivateKey 42 + var privMultibase string 36 43 switch cctx.String("type") { 37 44 case "", "P-256", "p256", "ES256", "secp256r1": 38 - priv, err := crypto.GeneratePrivateKeyP256() 45 + sec, err := crypto.GeneratePrivateKeyP256() 39 46 if err != nil { 40 47 return err 41 48 } 42 - fmt.Println(priv.Multibase()) 49 + privMultibase = sec.Multibase() 50 + priv = sec 43 51 case "K-256", "k256", "ES256K", "secp256k1": 44 - priv, err := crypto.GeneratePrivateKeyK256() 52 + sec, err := crypto.GeneratePrivateKeyK256() 45 53 if err != nil { 46 54 return err 47 55 } 48 - fmt.Println(priv.Multibase()) 56 + privMultibase = sec.Multibase() 57 + priv = sec 49 58 default: 50 59 return fmt.Errorf("unknown key type: %s", cctx.String("type")) 51 60 } 61 + if cctx.Bool("terse") { 62 + fmt.Println(privMultibase) 63 + return nil 64 + } 65 + pub, err := priv.PublicKey() 66 + if err != nil { 67 + return err 68 + } 69 + fmt.Printf("Key Type: %s\n", descKeyType(priv)) 70 + fmt.Printf("Secret Key (Multibase Syntax): save this securely (eg, add to password manager)\n\t%s\n", privMultibase) 71 + fmt.Printf("Public Key (DID Key Syntax): share or publish this (eg, in DID document)\n\t%s\n", pub.DIDKey()) 52 72 return nil 53 73 } 54 74 ··· 67 87 } 68 88 } 69 89 70 - func runCryptoInspect(cctx *cli.Context) error { 90 + func runKeyInspect(cctx *cli.Context) error { 71 91 s := cctx.Args().First() 72 92 if s == "" { 73 93 return fmt.Errorf("need to provide key as an argument")
+1 -1
cmd/goat/main.go
··· 37 37 cmdBsky, 38 38 cmdRecord, 39 39 cmdSyntax, 40 - cmdCrypto, 40 + cmdKey, 41 41 cmdPds, 42 42 } 43 43 return app.Run(args)
+126 -29
cmd/goat/plc.go
··· 20 20 Usage: "sub-commands for DID PLCs", 21 21 Flags: []cli.Flag{ 22 22 &cli.StringFlag{ 23 - Name: "plc-directory", 24 - Value: "https://plc.directory", 23 + Name: "plc-host", 24 + Usage: "method, hostname, and port of PLC registry", 25 + Value: "https://plc.directory", 26 + EnvVars: []string{"ATP_PLC_HOST"}, 25 27 }, 26 28 }, 27 29 Subcommands: []*cli.Command{ 28 - cmdPLCHistory, 29 - cmdPLCDump, 30 + &cli.Command{ 31 + Name: "history", 32 + Usage: "fetch operation log for individual DID", 33 + ArgsUsage: `<at-identifier>`, 34 + Flags: []cli.Flag{}, 35 + Action: runPLCHistory, 36 + }, 37 + &cli.Command{ 38 + Name: "data", 39 + Usage: "fetch current data (op) for individual DID", 40 + ArgsUsage: `<at-identifier>`, 41 + Flags: []cli.Flag{}, 42 + Action: runPLCData, 43 + }, 44 + &cli.Command{ 45 + Name: "dump", 46 + Usage: "output full operation log, as JSON lines", 47 + Flags: []cli.Flag{ 48 + &cli.StringFlag{ 49 + Name: "cursor", 50 + }, 51 + &cli.BoolFlag{ 52 + Name: "tail", 53 + }, 54 + }, 55 + Action: runPLCDump, 56 + }, 30 57 }, 31 58 } 32 59 33 - var cmdPLCHistory = &cli.Command{ 34 - Name: "history", 35 - Usage: "fetch operation log for individual DID", 36 - ArgsUsage: `<at-identifier>`, 37 - Flags: []cli.Flag{}, 38 - Action: runPLCHistory, 39 - } 40 - 41 60 func runPLCHistory(cctx *cli.Context) error { 42 61 ctx := context.Background() 43 - plcURL := cctx.String("plc-directory") 62 + plcHost := cctx.String("plc-host") 44 63 s := cctx.Args().First() 45 64 if s == "" { 46 65 return fmt.Errorf("need to provide account identifier as an argument") 47 66 } 48 67 49 68 dir := identity.BaseDirectory{ 50 - PLCURL: plcURL, 69 + PLCURL: plcHost, 51 70 } 52 71 53 72 id, err := syntax.ParseAtIdentifier(s) ··· 75 94 return fmt.Errorf("non-PLC DID method: %s", did.Method()) 76 95 } 77 96 78 - url := fmt.Sprintf("%s/%s/log", plcURL, did) 97 + url := fmt.Sprintf("%s/%s/log", plcHost, did) 79 98 resp, err := http.Get(url) 80 99 if err != nil { 81 100 return err 82 101 } 102 + defer resp.Body.Close() 83 103 if resp.StatusCode != http.StatusOK { 84 104 return fmt.Errorf("PLC HTTP request failed") 85 105 } ··· 106 126 return nil 107 127 } 108 128 109 - var cmdPLCDump = &cli.Command{ 110 - Name: "dump", 111 - Usage: "output full operation log, as JSON lines", 112 - Flags: []cli.Flag{ 113 - &cli.StringFlag{ 114 - Name: "cursor", 115 - }, 116 - &cli.BoolFlag{ 117 - Name: "tail", 118 - }, 119 - }, 120 - Action: runPLCDump, 129 + func runPLCData(cctx *cli.Context) error { 130 + ctx := context.Background() 131 + plcHost := cctx.String("plc-host") 132 + s := cctx.Args().First() 133 + if s == "" { 134 + return fmt.Errorf("need to provide account identifier as an argument") 135 + } 136 + 137 + dir := identity.BaseDirectory{ 138 + PLCURL: plcHost, 139 + } 140 + 141 + id, err := syntax.ParseAtIdentifier(s) 142 + if err != nil { 143 + return err 144 + } 145 + var did syntax.DID 146 + if id.IsDID() { 147 + did, err = id.AsDID() 148 + if err != nil { 149 + return err 150 + } 151 + } else { 152 + hdl, err := id.AsHandle() 153 + if err != nil { 154 + return err 155 + } 156 + did, err = dir.ResolveHandle(ctx, hdl) 157 + if err != nil { 158 + return err 159 + } 160 + } 161 + 162 + if did.Method() != "plc" { 163 + return fmt.Errorf("non-PLC DID method: %s", did.Method()) 164 + } 165 + 166 + plcData, err := fetchPLCData(ctx, plcHost, did) 167 + if err != nil { 168 + return err 169 + } 170 + 171 + b, err := json.MarshalIndent(plcData, "", " ") 172 + if err != nil { 173 + return err 174 + } 175 + fmt.Println(string(b)) 176 + return nil 121 177 } 122 178 123 179 func runPLCDump(cctx *cli.Context) error { 124 180 ctx := context.Background() 125 - plcURL := cctx.String("plc-directory") 181 + plcHost := cctx.String("plc-host") 126 182 client := http.DefaultClient 127 183 tailMode := cctx.Bool("tail") 128 184 ··· 132 188 } 133 189 var lastCursor string 134 190 135 - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/export", plcURL), nil) 191 + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/export", plcHost), nil) 136 192 if err != nil { 137 193 return err 138 194 } ··· 203 259 204 260 return nil 205 261 } 262 + 263 + type PLCService struct { 264 + Type string `json:"type"` 265 + Endpoint string `json:"endpoint"` 266 + } 267 + 268 + type PLCData struct { 269 + DID string `json:"did"` 270 + VerificationMethods map[string]string `json:"verificationMethods"` 271 + RotationKeys []string `json:"rotationKeys"` 272 + AlsoKnownAs []string `json:"alsoKnownAs"` 273 + Services map[string]PLCService `json:"services"` 274 + } 275 + 276 + func fetchPLCData(ctx context.Context, plcHost string, did syntax.DID) (*PLCData, error) { 277 + 278 + if plcHost == "" { 279 + return nil, fmt.Errorf("PLC host not configured") 280 + } 281 + 282 + url := fmt.Sprintf("%s/%s/data", plcHost, did) 283 + resp, err := http.Get(url) 284 + if err != nil { 285 + return nil, err 286 + } 287 + defer resp.Body.Close() 288 + if resp.StatusCode != http.StatusOK { 289 + return nil, fmt.Errorf("PLC HTTP request failed") 290 + } 291 + respBytes, err := io.ReadAll(resp.Body) 292 + if err != nil { 293 + return nil, err 294 + } 295 + 296 + var d PLCData 297 + err = json.Unmarshal(respBytes, &d) 298 + if err != nil { 299 + return nil, err 300 + } 301 + return &d, nil 302 + }