package repodid import ( "context" "fmt" "net/url" "strings" atcrypto "github.com/bluesky-social/indigo/atproto/crypto" "github.com/did-method-plc/go-didplc" "tangled.org/core/idresolver" ) type PreparedDID struct { RepoDid string SigningKeyRaw []byte op *didplc.RegularOp plcUrl string } func PrepareRepoDID(plcUrl, knotServiceUrl string) (*PreparedDID, error) { if plcUrl == "" { return nil, fmt.Errorf("PLC directory URL is not configured") } parsed, parseErr := url.Parse(plcUrl) if parseErr != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, fmt.Errorf("PLC directory URL is invalid: %q", plcUrl) } privKey, err := atcrypto.GeneratePrivateKeyK256() if err != nil { return nil, fmt.Errorf("generating signing key: %w", err) } pubKey, err := privKey.PublicKey() if err != nil { return nil, fmt.Errorf("deriving public key: %w", err) } didKey := pubKey.DIDKey() op := didplc.RegularOp{ Type: "plc_operation", RotationKeys: []string{didKey}, VerificationMethods: map[string]string{ "atproto": didKey, }, AlsoKnownAs: []string{}, Services: map[string]didplc.OpService{ "atproto_pds": { Type: "AtprotoPersonalDataServer", Endpoint: knotServiceUrl, }, }, Prev: nil, } if err := op.Sign(privKey); err != nil { return nil, fmt.Errorf("signing genesis op: %w", err) } repoDid, err := op.DID() if err != nil { return nil, fmt.Errorf("deriving DID from genesis: %w", err) } return &PreparedDID{ RepoDid: repoDid, SigningKeyRaw: privKey.Bytes(), op: &op, plcUrl: plcUrl, }, nil } func (p *PreparedDID) Submit(ctx context.Context) error { plcClient := didplc.Client{ DirectoryURL: p.plcUrl, UserAgent: "tangled-knot", } if err := plcClient.Submit(ctx, p.RepoDid, p.op); err != nil { return fmt.Errorf("submitting to PLC directory: %w", err) } return nil } const maxDidWebLength = 256 func VerifyRepoDIDWeb(ctx context.Context, resolver *idresolver.Resolver, repoDid, knotServiceUrl string) error { if !strings.HasPrefix(repoDid, "did:web:") { return fmt.Errorf("expected did:web, got: %s", repoDid) } if len(repoDid) > maxDidWebLength { return fmt.Errorf("did:web exceeds maximum length of %d characters", maxDidWebLength) } authority := strings.TrimPrefix(repoDid, "did:web:") if colonIdx := strings.IndexByte(authority, ':'); colonIdx >= 0 { authority = authority[:colonIdx] } if authority == "" || strings.ContainsAny(authority, "/#?@ ") { return fmt.Errorf("did:web has invalid authority: %s", repoDid) } ident, err := resolver.ResolveIdent(ctx, repoDid) if err != nil { return fmt.Errorf("resolving did:web document: %w", err) } if strings.TrimRight(ident.PDSEndpoint(), "/") != strings.TrimRight(knotServiceUrl, "/") { return fmt.Errorf( "did:web service endpoint %q does not match this knot %q", ident.PDSEndpoint(), knotServiceUrl, ) } if _, err := ident.PublicKey(); err != nil { return fmt.Errorf("did:web document missing valid atproto verification method: %w", err) } return nil }