Files

244 lines
7.9 KiB
Go

// Package did provides shared did:web and did:plc identity management for ATCR services.
//
// Both the hold and labeler services declare an ATProto identity with a signing key
// and one or more service endpoints. This package generalizes the genesis/update/load
// flow so callers only have to specify their verification key fragment name and the
// service entries they want to register.
package did
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// Service is a service entry in a DID document or PLC operation.
type Service struct {
Type string
Endpoint string
}
// Config configures DID identity loading or creation.
type Config struct {
// Method is "web" or "plc".
Method string
// PublicURL is the externally reachable URL of the service.
PublicURL string
// DBPath is a directory used to persist did.txt for did:plc identities.
DBPath string
// SigningKeyPath is the on-disk path for the K-256 signing key (will be generated if missing).
SigningKeyPath string
// RotationKey is a multibase-encoded private key used to sign PLC operations (optional).
// If empty for did:plc, a new rotation key is generated and logged once for the operator.
RotationKey string
// PLCDirectoryURL is the PLC directory endpoint.
PLCDirectoryURL string
// DID overrides the persisted DID (used for adoption/recovery of an existing did:plc).
DID string
// VerificationKeyName is the fragment used in the DID document and PLC operation
// for the signing key (e.g. "atproto" for a PDS, "atproto_label" for a labeler).
VerificationKeyName string
// Services lists service entries keyed by service id (e.g. "atproto_pds", "atproto_labeler").
Services map[string]Service
}
// LoadOrCreate returns the service's DID. did:web is derived deterministically from
// PublicURL; did:plc is loaded from disk or created and registered with the PLC directory.
func LoadOrCreate(ctx context.Context, cfg Config) (string, error) {
if cfg.Method != "plc" {
return GenerateDIDFromURL(cfg.PublicURL), nil
}
if cfg.VerificationKeyName == "" {
return "", fmt.Errorf("did: VerificationKeyName is required for did:plc")
}
if len(cfg.Services) == 0 {
return "", fmt.Errorf("did: at least one service entry is required for did:plc")
}
didPath := filepath.Join(cfg.DBPath, "did.txt")
var d string
if cfg.DID != "" {
if !strings.HasPrefix(cfg.DID, "did:plc:") {
return "", fmt.Errorf("did: DID must be a did:plc identifier, got %q", cfg.DID)
}
d = cfg.DID
slog.Info("Using DID from config (adoption/recovery)", "did", d)
} else if data, err := os.ReadFile(didPath); err == nil {
val := strings.TrimSpace(string(data))
if strings.HasPrefix(val, "did:plc:") {
d = val
slog.Info("Loaded existing did:plc identity", "did", d)
}
}
if d != "" {
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
}
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
}
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("did: failed to load signing key: %w", err)
}
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
if err := EnsureCurrent(ctx, d, rotationKey, signingKey, cfg); err != nil {
slog.Warn("Failed to verify PLC identity is current (will retry on next restart)",
"did", d, "error", err)
}
return d, nil
}
slog.Info("Creating new did:plc identity")
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("did: failed to load signing key: %w", err)
}
var rotationKey atcrypto.PrivateKeyExportable
if cfg.RotationKey != "" {
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
if err != nil {
return "", fmt.Errorf("did: failed to parse rotation_key: %w", err)
}
} else {
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
if genErr != nil {
return "", fmt.Errorf("did: failed to generate rotation key: %w", genErr)
}
rotationKey = rawKey
slog.Warn("Generated new rotation key — save this in your config as rotation_key",
"rotation_key", rawKey.Multibase())
}
d, err = CreateIdentity(ctx, rotationKey, signingKey, cfg)
if err != nil {
return "", fmt.Errorf("did: failed to create PLC identity: %w", err)
}
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
}
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
}
slog.Info("Created did:plc identity", "did", d, "plc_directory", cfg.PLCDirectoryURL)
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
return d, nil
}
// DIDDocument is the JSON shape we serve for did:web identities.
type DIDDocument struct {
Context []string `json:"@context"`
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
VerificationMethod []VerificationMethod `json:"verificationMethod"`
Authentication []string `json:"authentication,omitempty"`
AssertionMethod []string `json:"assertionMethod,omitempty"`
Service []DIDService `json:"service,omitempty"`
}
// VerificationMethod is a public key entry in a DID document.
type VerificationMethod struct {
ID string `json:"id"`
Type string `json:"type"`
Controller string `json:"controller"`
PublicKeyMultibase string `json:"publicKeyMultibase"`
}
// DIDService is a service entry in a DID document.
type DIDService struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
}
// BuildDIDDocument constructs a DID document for a did:web identity. The verification
// method fragment matches verificationKeyName (e.g. "#atproto" or "#atproto_label");
// pass "" to default to "atproto". Authentication is only added for the standard
// "atproto" key per the bsky/PDS pattern.
func BuildDIDDocument(did, publicURL string, signingKey *atcrypto.PrivateKeyK256, verificationKeyName string, services map[string]Service) (*DIDDocument, error) {
host, err := hostWithPort(publicURL)
if err != nil {
return nil, err
}
pub, err := signingKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("did: failed to get public key: %w", err)
}
keyName := verificationKeyName
if keyName == "" {
keyName = "atproto"
}
doc := &DIDDocument{
Context: []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
},
ID: did,
AlsoKnownAs: []string{"at://" + host},
VerificationMethod: []VerificationMethod{
{
ID: fmt.Sprintf("%s#%s", did, keyName),
Type: "Multikey",
Controller: did,
PublicKeyMultibase: pub.Multibase(),
},
},
}
if keyName == "atproto" {
doc.Authentication = []string{fmt.Sprintf("%s#atproto", did)}
}
for id, svc := range services {
doc.Service = append(doc.Service, DIDService{
ID: "#" + id,
Type: svc.Type,
ServiceEndpoint: svc.Endpoint,
})
}
return doc, nil
}
// MarshalDIDDocument is a convenience for serving a DID doc as indented JSON.
func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
return json.MarshalIndent(doc, "", " ")
}
func hostWithPort(publicURL string) (string, error) {
u, err := url.Parse(publicURL)
if err != nil {
return "", fmt.Errorf("did: failed to parse public URL: %w", err)
}
host := u.Hostname()
if port := u.Port(); port != "" && port != "80" && port != "443" {
host = host + ":" + port
}
return host, nil
}