Monorepo for Tangled
1package repodid
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8
9 atcrypto "github.com/bluesky-social/indigo/atproto/crypto"
10 "github.com/did-method-plc/go-didplc"
11 "tangled.org/core/idresolver"
12)
13
14type PreparedDID struct {
15 RepoDid string
16 SigningKeyRaw []byte
17 op *didplc.RegularOp
18 plcUrl string
19}
20
21func PrepareRepoDID(plcUrl, knotServiceUrl string) (*PreparedDID, error) {
22 if plcUrl == "" {
23 return nil, fmt.Errorf("PLC directory URL is not configured")
24 }
25 parsed, parseErr := url.Parse(plcUrl)
26 if parseErr != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
27 return nil, fmt.Errorf("PLC directory URL is invalid: %q", plcUrl)
28 }
29
30 privKey, err := atcrypto.GeneratePrivateKeyK256()
31 if err != nil {
32 return nil, fmt.Errorf("generating signing key: %w", err)
33 }
34
35 pubKey, err := privKey.PublicKey()
36 if err != nil {
37 return nil, fmt.Errorf("deriving public key: %w", err)
38 }
39
40 didKey := pubKey.DIDKey()
41
42 op := didplc.RegularOp{
43 Type: "plc_operation",
44 RotationKeys: []string{didKey},
45 VerificationMethods: map[string]string{
46 "atproto": didKey,
47 },
48 AlsoKnownAs: []string{},
49 Services: map[string]didplc.OpService{
50 "atproto_pds": {
51 Type: "AtprotoPersonalDataServer",
52 Endpoint: knotServiceUrl,
53 },
54 },
55 Prev: nil,
56 }
57
58 if err := op.Sign(privKey); err != nil {
59 return nil, fmt.Errorf("signing genesis op: %w", err)
60 }
61
62 repoDid, err := op.DID()
63 if err != nil {
64 return nil, fmt.Errorf("deriving DID from genesis: %w", err)
65 }
66
67 return &PreparedDID{
68 RepoDid: repoDid,
69 SigningKeyRaw: privKey.Bytes(),
70 op: &op,
71 plcUrl: plcUrl,
72 }, nil
73}
74
75func (p *PreparedDID) Submit(ctx context.Context) error {
76 plcClient := didplc.Client{
77 DirectoryURL: p.plcUrl,
78 UserAgent: "tangled-knot",
79 }
80 if err := plcClient.Submit(ctx, p.RepoDid, p.op); err != nil {
81 return fmt.Errorf("submitting to PLC directory: %w", err)
82 }
83 return nil
84}
85
86const maxDidWebLength = 256
87
88func VerifyRepoDIDWeb(ctx context.Context, resolver *idresolver.Resolver, repoDid, knotServiceUrl string) error {
89 if !strings.HasPrefix(repoDid, "did:web:") {
90 return fmt.Errorf("expected did:web, got: %s", repoDid)
91 }
92
93 if len(repoDid) > maxDidWebLength {
94 return fmt.Errorf("did:web exceeds maximum length of %d characters", maxDidWebLength)
95 }
96
97 authority := strings.TrimPrefix(repoDid, "did:web:")
98 if colonIdx := strings.IndexByte(authority, ':'); colonIdx >= 0 {
99 authority = authority[:colonIdx]
100 }
101 if authority == "" || strings.ContainsAny(authority, "/#?@ ") {
102 return fmt.Errorf("did:web has invalid authority: %s", repoDid)
103 }
104
105 ident, err := resolver.ResolveIdent(ctx, repoDid)
106 if err != nil {
107 return fmt.Errorf("resolving did:web document: %w", err)
108 }
109
110 if strings.TrimRight(ident.PDSEndpoint(), "/") != strings.TrimRight(knotServiceUrl, "/") {
111 return fmt.Errorf(
112 "did:web service endpoint %q does not match this knot %q",
113 ident.PDSEndpoint(), knotServiceUrl,
114 )
115 }
116
117 if _, err := ident.PublicKey(); err != nil {
118 return fmt.Errorf("did:web document missing valid atproto verification method: %w", err)
119 }
120
121 return nil
122}