mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16:54:15 +00:00
244 lines
7.9 KiB
Go
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
|
|
}
|