1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "slices"
9
10 "github.com/bluesky-social/indigo/api/agnostic"
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"
14
15 "github.com/urfave/cli/v2"
16)
17
18var cmdAccountPlc = &cli.Command{
19 Name: "plc",
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 },
29 Subcommands: []*cli.Command{
30 &cli.Command{
31 Name: "recommended",
32 Usage: "list recommended DID fields for current account",
33 Action: runAccountPlcRecommended,
34 },
35 &cli.Command{
36 Name: "request-token",
37 Usage: "request a 2FA token (by email) for signing op",
38 Action: runAccountPlcRequestToken,
39 },
40 &cli.Command{
41 Name: "sign",
42 Usage: "sign a PLC operation",
43 ArgsUsage: `<json-file>`,
44 Action: runAccountPlcSign,
45 Flags: []cli.Flag{
46 &cli.StringFlag{
47 Name: "token",
48 Usage: "2FA token for PLC operation signing request",
49 },
50 },
51 },
52 &cli.Command{
53 Name: "submit",
54 Usage: "submit a PLC operation (via PDS)",
55 ArgsUsage: `<json-file>`,
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 },
78 },
79 },
80}
81
82func runAccountPlcRecommended(cctx *cli.Context) error {
83 ctx := context.Background()
84
85 xrpcc, err := loadAuthClient(ctx)
86 if err == ErrNoAuthSession {
87 return fmt.Errorf("auth required, but not logged in")
88 } else if err != nil {
89 return err
90 }
91
92 resp, err := agnostic.IdentityGetRecommendedDidCredentials(ctx, xrpcc)
93 if err != nil {
94 return err
95 }
96
97 b, err := json.MarshalIndent(resp, "", " ")
98 if err != nil {
99 return err
100 }
101
102 fmt.Println(string(b))
103 return nil
104}
105
106func runAccountPlcRequestToken(cctx *cli.Context) error {
107 ctx := context.Background()
108
109 xrpcc, err := loadAuthClient(ctx)
110 if err == ErrNoAuthSession {
111 return fmt.Errorf("auth required, but not logged in")
112 } else if err != nil {
113 return err
114 }
115
116 err = comatproto.IdentityRequestPlcOperationSignature(ctx, xrpcc)
117 if err != nil {
118 return err
119 }
120
121 fmt.Println("Success; check email for token.")
122 return nil
123}
124
125func runAccountPlcSign(cctx *cli.Context) error {
126 ctx := context.Background()
127
128 opPath := cctx.Args().First()
129 if opPath == "" {
130 return fmt.Errorf("need to provide JSON file path as an argument")
131 }
132
133 xrpcc, err := loadAuthClient(ctx)
134 if err == ErrNoAuthSession {
135 return fmt.Errorf("auth required, but not logged in")
136 } else if err != nil {
137 return err
138 }
139
140 fileBytes, err := os.ReadFile(opPath)
141 if err != nil {
142 return err
143 }
144
145 var body agnostic.IdentitySignPlcOperation_Input
146 if err = json.Unmarshal(fileBytes, &body); err != nil {
147 return fmt.Errorf("failed decoding PLC op JSON: %w", err)
148 }
149
150 token := cctx.String("token")
151 if token != "" {
152 body.Token = &token
153 }
154
155 resp, err := agnostic.IdentitySignPlcOperation(ctx, xrpcc, &body)
156 if err != nil {
157 return err
158 }
159
160 b, err := json.MarshalIndent(resp.Operation, "", " ")
161 if err != nil {
162 return err
163 }
164
165 fmt.Println(string(b))
166 return nil
167}
168
169func runAccountPlcSubmit(cctx *cli.Context) error {
170 ctx := context.Background()
171
172 opPath := cctx.Args().First()
173 if opPath == "" {
174 return fmt.Errorf("need to provide JSON file path as an argument")
175 }
176
177 xrpcc, err := loadAuthClient(ctx)
178 if err == ErrNoAuthSession {
179 return fmt.Errorf("auth required, but not logged in")
180 } else if err != nil {
181 return err
182 }
183
184 fileBytes, err := os.ReadFile(opPath)
185 if err != nil {
186 return err
187 }
188
189 var op json.RawMessage
190 if err = json.Unmarshal(fileBytes, &op); err != nil {
191 return fmt.Errorf("failed decoding PLC op JSON: %w", err)
192 }
193
194 err = agnostic.IdentitySubmitPlcOperation(ctx, xrpcc, &agnostic.IdentitySubmitPlcOperation_Input{
195 Operation: &op,
196 })
197 if err != nil {
198 return fmt.Errorf("failed submitting PLC op via PDS: %w", err)
199 }
200
201 return nil
202}
203
204func 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
232func 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}