Files
at-container-registry/pkg/testpds/identity.go
T

99 lines
3.5 KiB
Go

// Package testpds provides an in-process fake ATProto PDS for integration
// tests. It implements the minimal XRPC surface that AppView and Hold call
// during push/pull (createSession, getServiceAuth, repo.put/get/list/delete
// Record, identity.resolveHandle), plus an identity.Directory implementation
// so DID resolution short-circuits the network.
package testpds
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// Identity is a single account on the fake PDS. Each identity has its own
// K-256 signing keypair used to sign service-auth JWTs the Hold service
// verifies.
type Identity struct {
DID syntax.DID
Handle syntax.Handle
SigningKey *atcrypto.PrivateKeyK256
Password string // synthetic app-password; opaque to tests
AccessToken string // synthetic accessJwt returned by createSession
pdsURL string // base URL of the fake PDS serving this identity
}
// newIdentity allocates an identity with a fresh keypair. didHostEscaped is
// the percent-encoded host:port that did:web will encode in the DID, e.g.
// "127.0.0.1%3A45123".
func newIdentity(pdsURL, didHostEscaped, handle string) (*Identity, error) {
priv, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
return nil, fmt.Errorf("generate K-256: %w", err)
}
hdl, err := syntax.ParseHandle(handle)
if err != nil {
return nil, fmt.Errorf("invalid handle %q: %w", handle, err)
}
didStr := fmt.Sprintf("did:web:%s:user:%s", didHostEscaped, hdl.String())
did, err := syntax.ParseDID(didStr)
if err != nil {
return nil, fmt.Errorf("invalid synthesized DID %q: %w", didStr, err)
}
rb := make([]byte, 16)
if _, err := rand.Read(rb); err != nil {
return nil, err
}
return &Identity{
DID: did,
Handle: hdl,
SigningKey: priv,
Password: "test-pass-" + hex.EncodeToString(rb[:4]),
AccessToken: "test-access-" + hex.EncodeToString(rb),
pdsURL: pdsURL,
}, nil
}
// toIndigoIdentity returns the indigo identity.Identity view of this account.
// It contains the same fields the production code reads when resolving DIDs:
// AlsoKnownAs (for handle), Services["atproto_pds"] (for the PDS URL), and
// Keys["atproto"] (for service-auth JWT verification).
func (i *Identity) toIndigoIdentity() (*identity.Identity, error) {
pub, err := i.SigningKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("derive public key: %w", err)
}
pubK256, ok := pub.(*atcrypto.PublicKeyK256)
if !ok {
return nil, fmt.Errorf("expected K-256 public key, got %T", pub)
}
pubMultibase := pubK256.Multibase()
return &identity.Identity{
DID: i.DID,
Handle: i.Handle,
AlsoKnownAs: []string{"at://" + i.Handle.String()},
Services: map[string]identity.ServiceEndpoint{
"atproto_pds": {Type: "AtprotoPersonalDataServer", URL: i.pdsURL},
},
Keys: map[string]identity.VerificationMethod{
"atproto": {Type: "Multikey", PublicKeyMultibase: pubMultibase},
},
}, nil
}
// didWebForHost builds the percent-encoded did:web host component for the
// given listener address. did:web requires the port colon be encoded as %3A.
func didWebForHost(addr string) string {
host := strings.TrimPrefix(addr, "http://")
host = strings.TrimPrefix(host, "https://")
// url.QueryEscape would encode dots and slashes; we only need to swap the
// port colon. did:web also forbids userinfo, paths beyond the optional
// path segment, etc., which we don't generate.
return strings.Replace(host, ":", "%3A", 1)
}