mirror of
https://github.com/versia-pub/versia-go.git
synced 2026-03-12 20:19:15 +01:00
refactor!: working WD-4 user discovery
This commit is contained in:
parent
cf0053312d
commit
61891d891a
91 changed files with 12768 additions and 5562 deletions
|
|
@ -1,15 +1,7 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// User represents a user object in the Lysand protocol. For more information, see the [Spec].
|
||||
|
|
@ -20,7 +12,7 @@ type User struct {
|
|||
|
||||
// PublicKey is the public key of the user.
|
||||
// https://lysand.org/objects/user#public-key
|
||||
PublicKey PublicKey `json:"public_key"`
|
||||
PublicKey UserPublicKey `json:"public_key"`
|
||||
|
||||
// DisplayName is the display name of the user.
|
||||
// https://lysand.org/objects/user#display-name
|
||||
|
|
@ -66,14 +58,6 @@ type User struct {
|
|||
// https://lysand.org/objects/user#following
|
||||
Following *URL `json:"following"`
|
||||
|
||||
// Likes is the likes of the user.
|
||||
// https://lysand.org/objects/user#likes
|
||||
Likes *URL `json:"likes"`
|
||||
|
||||
// Dislikes is the dislikes of the user.
|
||||
// https://lysand.org/objects/user#dislikes
|
||||
Dislikes *URL `json:"dislikes"`
|
||||
|
||||
// Inbox is the inbox of the user.
|
||||
// https://lysand.org/objects/user#posts
|
||||
Inbox *URL `json:"inbox"`
|
||||
|
|
@ -94,60 +78,3 @@ type Field struct {
|
|||
Key TextContentTypeMap `json:"key"`
|
||||
Value TextContentTypeMap `json:"value"`
|
||||
}
|
||||
|
||||
func (c *FederationClient) GetUser(ctx context.Context, uri *url.URL) (*User, error) {
|
||||
resp, body, err := c.rawGET(ctx, uri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &User{}
|
||||
if err := json.Unmarshal(body, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fedHeaders, err := ExtractFederationHeaders(resp.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v := Verifier{ed25519.PublicKey(user.PublicKey.PublicKey)}
|
||||
if !v.Verify("GET", uri, body, fedHeaders) {
|
||||
c.log.V(2).Info("signature verification failed", "user", user.URI.String())
|
||||
return nil, fmt.Errorf("signature verification failed")
|
||||
}
|
||||
c.log.V(2).Info("signature verification succeeded", "user", user.URI.String())
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (c *FederationClient) SendToInbox(ctx context.Context, signer Signer, user *User, object any) ([]byte, error) {
|
||||
uri := user.Inbox.ToStd()
|
||||
|
||||
body, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sigData := NewSignatureData("POST", base64.StdEncoding.EncodeToString(nonce), uri, hashSHA256(body))
|
||||
sig := signer.Sign(*sigData)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", uri.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sig.Inject(req.Header)
|
||||
|
||||
_, respBody, err := c.doReq(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,5 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (c *FederationClient) ValidateSignatureHeader(req *http.Request) (bool, error) {
|
||||
fedHeaders, err := ExtractFederationHeaders(req.Header)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// TODO: Fetch user from database instead of using the URI
|
||||
user, err := c.GetUser(req.Context(), fedHeaders.SignedBy)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
body, err := copyBody(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
v := Verifier{ed25519.PublicKey(user.PublicKey.PublicKey)}
|
||||
valid := v.Verify(req.Method, req.URL, body, fedHeaders)
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
func hashSHA256(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func must[In any, Out any](fn func(In) (Out, error), v In) Out {
|
||||
out, err := fn(v)
|
||||
if err != nil {
|
||||
|
|
@ -45,17 +8,3 @@ func must[In any, Out any](fn func(In) (Out, error), v In) Out {
|
|||
|
||||
return out
|
||||
}
|
||||
|
||||
func copyBody(req *http.Request) ([]byte, error) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := req.Body.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
|
|
|||
23
pkg/lysand/crypto/keys.go
Normal file
23
pkg/lysand/crypto/keys.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type UnknownPublicKeyTypeError struct {
|
||||
Got string
|
||||
}
|
||||
|
||||
func (i UnknownPublicKeyTypeError) Error() string {
|
||||
return fmt.Sprintf("unknown public key type: \"%s\"", i.Got)
|
||||
}
|
||||
|
||||
func ToTypedKey(algorithm string, raw []byte) (any, error) {
|
||||
switch algorithm {
|
||||
case "ed25519":
|
||||
return ed25519.PublicKey(raw), nil
|
||||
default:
|
||||
return nil, UnknownPublicKeyTypeError{algorithm}
|
||||
}
|
||||
}
|
||||
9
pkg/lysand/crypto/sha256.go
Normal file
9
pkg/lysand/crypto/sha256.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package versiacrypto
|
||||
|
||||
import "crypto/sha256"
|
||||
|
||||
func SHA256(data []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
29
pkg/lysand/crypto/verify.go
Normal file
29
pkg/lysand/crypto/verify.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type InvalidPublicKeyTypeError struct {
|
||||
Got reflect.Type
|
||||
}
|
||||
|
||||
func (i InvalidPublicKeyTypeError) Error() string {
|
||||
return fmt.Sprintf("failed to convert public key of type \"%s\"", i.Got.String())
|
||||
}
|
||||
|
||||
type Verify = func(data, signature []byte) bool
|
||||
|
||||
func NewVerify(pubKey crypto.PublicKey) (Verify, error) {
|
||||
switch pk := pubKey.(type) {
|
||||
case ed25519.PublicKey:
|
||||
return func(data, signature []byte) bool {
|
||||
return ed25519.Verify(pk, data, signature)
|
||||
}, nil
|
||||
default:
|
||||
return nil, InvalidPublicKeyTypeError{reflect.TypeOf(pk)}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-logr/logr"
|
||||
"io"
|
||||
"github.com/lysand-org/versia-go/pkg/protoretry"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
|
@ -22,6 +21,7 @@ func (e *ResponseError) Error() string {
|
|||
type FederationClient struct {
|
||||
log logr.Logger
|
||||
httpC *http.Client
|
||||
hc *protoretry.Client
|
||||
}
|
||||
|
||||
type Opt func(c *FederationClient)
|
||||
|
|
@ -53,49 +53,11 @@ func NewClient(opts ...Opt) *FederationClient {
|
|||
useragent: "github.com/lysand-org/versia-go/pkg/lysand#0.0.1",
|
||||
}
|
||||
|
||||
c.hc = protoretry.New(c.httpC)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *FederationClient) rawGET(ctx context.Context, uri *url.URL) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", uri.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.doReq(req)
|
||||
}
|
||||
|
||||
func (c *FederationClient) rawPOST(ctx context.Context, uri *url.URL, body io.Reader) (*http.Response, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", uri.String(), body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.doReq(req)
|
||||
}
|
||||
|
||||
func (c *FederationClient) doReq(req *http.Request) (*http.Response, []byte, error) {
|
||||
resp, err := c.httpC.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return resp, nil, &ResponseError{
|
||||
StatusCode: resp.StatusCode,
|
||||
URL: req.URL,
|
||||
}
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp, nil, err
|
||||
}
|
||||
|
||||
return resp, respBody, nil
|
||||
}
|
||||
|
||||
type federationClientHTTPTransport struct {
|
||||
inner http.RoundTripper
|
||||
useragent string
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ func (f *FederationHeaders) Inject(h http.Header) {
|
|||
h.Set("x-signature", base64.StdEncoding.EncodeToString(f.Signature))
|
||||
}
|
||||
|
||||
func (f *FederationHeaders) Headers() map[string]string {
|
||||
return map[string]string{
|
||||
"x-signed-by": f.SignedBy.String(),
|
||||
"x-nonce": f.Nonce,
|
||||
"x-signature": base64.StdEncoding.EncodeToString(f.Signature),
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractFederationHeaders(h http.Header) (*FederationHeaders, error) {
|
||||
signedBy := h.Get("x-signed-by")
|
||||
if signedBy == "" {
|
||||
|
|
@ -41,14 +49,19 @@ func ExtractFederationHeaders(h http.Header) (*FederationHeaders, error) {
|
|||
return nil, fmt.Errorf("missing x-nonce header")
|
||||
}
|
||||
|
||||
signature := h.Get("x-signature")
|
||||
if signature == "" {
|
||||
rawSignature := h.Get("x-signature")
|
||||
if rawSignature == "" {
|
||||
return nil, fmt.Errorf("missing x-signature header")
|
||||
}
|
||||
|
||||
signature, err := base64.StdEncoding.DecodeString(rawSignature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FederationHeaders{
|
||||
SignedBy: u,
|
||||
Nonce: nonce,
|
||||
Signature: []byte(signature),
|
||||
Signature: signature,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
111
pkg/lysand/instance_metadata.go
Normal file
111
pkg/lysand/instance_metadata.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// InstanceMetadata represents the metadata of a Lysand instance. For more information, see the [Spec].
|
||||
//
|
||||
// ! Unlike other entities, instance metadata is not meant to be federated.
|
||||
//
|
||||
// [Spec]: https://versia.pub/entities/instance-metadata
|
||||
type InstanceMetadata struct {
|
||||
// Type is always "InstanceMetadata"
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extensions is a map of active extensions
|
||||
Extensions Extensions `json:"extensions,omitempty"`
|
||||
|
||||
// Name is the name of the instance
|
||||
Name string `json:"name"`
|
||||
|
||||
// Description is a description of the instance
|
||||
Description *string `json:"description,omitempty"`
|
||||
|
||||
// Host is the hostname of the instance, including the port
|
||||
Host string `json:"host,omitempty"`
|
||||
|
||||
// PublicKey is the public key of the instance
|
||||
PublicKey InstancePublicKey `json:"public_key"`
|
||||
|
||||
// SharedInbox is the URL to the instance's shared inbox
|
||||
SharedInbox *URL `json:"shared_inbox,omitempty"`
|
||||
|
||||
// Moderators is a URL to a collection of moderators
|
||||
Moderators *URL `json:"moderators,omitempty"`
|
||||
|
||||
// Admins is a URL to a collection of administrators
|
||||
Admins *URL `json:"admins,omitempty"`
|
||||
|
||||
// Logo is the URL to the instance's logo
|
||||
Logo *ImageContentTypeMap `json:"logo,omitempty"`
|
||||
|
||||
// Banner is the URL to the instance's banner
|
||||
Banner *ImageContentTypeMap `json:"banner,omitempty"`
|
||||
|
||||
// Software is information about the instance software
|
||||
Software InstanceSoftware `json:"software"`
|
||||
|
||||
// Compatibility is information about the instance's compatibility with different Lysand versions
|
||||
Compatibility InstanceCompatibility `json:"compatibility"`
|
||||
}
|
||||
|
||||
func (s InstanceMetadata) MarshalJSON() ([]byte, error) {
|
||||
type instanceMetadata InstanceMetadata
|
||||
s2 := instanceMetadata(s)
|
||||
s2.Type = "InstanceMetadata"
|
||||
return json.Marshal(s2)
|
||||
}
|
||||
|
||||
// InstanceSoftware represents the software of a Lysand instance.
|
||||
type InstanceSoftware struct {
|
||||
// Name is the name of the instance software
|
||||
Name string `json:"name"`
|
||||
// Version is the version of the instance software
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InstanceCompatibility represents the compatibility of a Lysand instance.
|
||||
type InstanceCompatibility struct {
|
||||
// Versions is a list of versions of Lysand the instance is compatible with
|
||||
Versions []string `json:"versions"`
|
||||
|
||||
// Extensions is a list of extensions supported by the instance
|
||||
Extensions []string `json:"extensions"`
|
||||
}
|
||||
|
||||
// InstancePublicKey represents the public key of a Versia instance.
|
||||
type InstancePublicKey struct {
|
||||
// Algorithm can only be `ed25519` for now
|
||||
Algorithm string `json:"algorithm"`
|
||||
|
||||
Key *SPKIPublicKey `json:"-"`
|
||||
RawKey json.RawMessage `json:"key"`
|
||||
}
|
||||
|
||||
func (k *InstancePublicKey) UnmarshalJSON(raw []byte) error {
|
||||
type t InstancePublicKey
|
||||
k2 := (*t)(k)
|
||||
if err := json.Unmarshal(raw, k2); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
if k2.Key, err = unmarshalSPKIPubKey(k2.Algorithm, k2.RawKey); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k InstancePublicKey) MarshalJSON() ([]byte, error) {
|
||||
type t InstancePublicKey
|
||||
k2 := t(k)
|
||||
|
||||
var err error
|
||||
if k2.RawKey, err = k2.Key.MarshalJSON(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(k2)
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/semver"
|
||||
)
|
||||
|
||||
// ServerMetadata represents the metadata of a Lysand server. For more information, see the [Spec].
|
||||
//
|
||||
// ! Unlike other objects, server metadata is not meant to be federated.
|
||||
//
|
||||
// [Spec]: https://lysand.org/objects/server-metadata
|
||||
type ServerMetadata struct {
|
||||
// Type is always "ServerMetadata"
|
||||
// https://lysand.org/objects/server-metadata#type
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extensions is a map of active extensions
|
||||
// https://lysand.org/objects/server-metadata#extensions
|
||||
Extensions Extensions `json:"extensions,omitempty"`
|
||||
|
||||
// Name is the name of the server
|
||||
// https://lysand.org/objects/server-metadata#name
|
||||
Name string `json:"name"`
|
||||
|
||||
// Version is the version of the server software
|
||||
// https://lysand.org/objects/server-metadata#version
|
||||
Version *semver.Version `json:"version"`
|
||||
|
||||
// Description is a description of the server
|
||||
// https://lysand.org/objects/server-metadata#description
|
||||
Description *string `json:"description,omitempty"`
|
||||
|
||||
// Website is the URL to the server's website
|
||||
// https://lysand.org/objects/server-metadata#website
|
||||
Website *URL `json:"website,omitempty"`
|
||||
|
||||
// Moderators is a list of URLs to moderators
|
||||
// https://lysand.org/objects/server-metadata#moderators
|
||||
Moderators []*URL `json:"moderators,omitempty"`
|
||||
|
||||
// Admins is a list of URLs to administrators
|
||||
// https://lysand.org/objects/server-metadata#admins
|
||||
Admins []*URL `json:"admins,omitempty"`
|
||||
|
||||
// Logo is the URL to the server's logo
|
||||
// https://lysand.org/objects/server-metadata#logo
|
||||
Logo *ImageContentTypeMap `json:"logo,omitempty"`
|
||||
|
||||
// Banner is the URL to the server's banner
|
||||
// https://lysand.org/objects/server-metadata#banner
|
||||
Banner *ImageContentTypeMap `json:"banner,omitempty"`
|
||||
|
||||
// SupportedExtensions is a list of supported extensions
|
||||
SupportedExtensions []string `json:"supported_extensions"`
|
||||
}
|
||||
|
||||
func (s ServerMetadata) MarshalJSON() ([]byte, error) {
|
||||
type serverMetadata ServerMetadata
|
||||
s2 := serverMetadata(s)
|
||||
s2.Type = "ServerMetadata"
|
||||
return json.Marshal(s2)
|
||||
}
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
versiacrypto "github.com/lysand-org/versia-go/pkg/lysand/crypto"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -32,8 +35,16 @@ func (s *SignatureData) String() string {
|
|||
return fmt.Sprintf("%s %s?%s %s %s", strings.ToLower(s.RequestMethod), s.URL.Path, s.URL.RawQuery, s.Nonce, base64.StdEncoding.EncodeToString(s.Digest))
|
||||
}
|
||||
|
||||
func (s *SignatureData) Validate(pubKey ed25519.PublicKey, signature []byte) bool {
|
||||
return ed25519.Verify(pubKey, []byte(s.String()), signature)
|
||||
func (s *SignatureData) Validate(pubKey crypto.PublicKey, signature []byte) bool {
|
||||
data := []byte(s.String())
|
||||
|
||||
verify, err := versiacrypto.NewVerify(pubKey)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return verify(data, signature)
|
||||
}
|
||||
|
||||
func (s *SignatureData) Sign(privKey ed25519.PrivateKey) []byte {
|
||||
|
|
@ -54,11 +65,10 @@ func (s Signer) Sign(signatureData SignatureData) *FederationHeaders {
|
|||
}
|
||||
|
||||
type Verifier struct {
|
||||
PublicKey ed25519.PublicKey
|
||||
PublicKey crypto.PublicKey
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(method string, u *url.URL, body []byte, fedHeaders *FederationHeaders) bool {
|
||||
sigData := NewSignatureData(method, fedHeaders.Nonce, u, hashSHA256(body))
|
||||
|
||||
return sigData.Validate(v.PublicKey, fedHeaders.Signature)
|
||||
return NewSignatureData(method, fedHeaders.Nonce, u, versiacrypto.SHA256(body)).
|
||||
Validate(v.PublicKey, fedHeaders.Signature)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,92 @@
|
|||
package lysand
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPublicKeyType = errors.New("invalid public key type")
|
||||
)
|
||||
|
||||
// PublicKey represents a public key for a user. For more information, see the [Spec].
|
||||
// UserPublicKey represents a public key for a user. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://lysand.org/security/keys#public-key-cryptography
|
||||
type PublicKey struct {
|
||||
PublicKey SPKIPublicKey `json:"public_key"`
|
||||
Actor *URL `json:"actor"`
|
||||
type UserPublicKey struct {
|
||||
Actor *URL `json:"actor"`
|
||||
|
||||
// Algorithm can only be `ed25519` for now
|
||||
Algorithm string `json:"algorithm"`
|
||||
|
||||
Key *SPKIPublicKey `json:"-"`
|
||||
RawKey json.RawMessage `json:"key"`
|
||||
}
|
||||
|
||||
// SPKIPublicKey is a type that represents a [ed25519.PublicKey] in the SPKI
|
||||
// format.
|
||||
type SPKIPublicKey ed25519.PublicKey
|
||||
func (k *UserPublicKey) UnmarshalJSON(raw []byte) error {
|
||||
type t UserPublicKey
|
||||
k2 := (*t)(k)
|
||||
|
||||
// UnmarshalJSON decodes the public key from a base64 encoded string and then unmarshals it from the SPKI form.
|
||||
func (k *SPKIPublicKey) UnmarshalJSON(raw []byte) error {
|
||||
rawStr := ""
|
||||
if err := json.Unmarshal(raw, &rawStr); err != nil {
|
||||
if err := json.Unmarshal(raw, k2); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(rawStr)
|
||||
if err != nil {
|
||||
var err error
|
||||
if k2.Key, err = unmarshalSPKIPubKey(k2.Algorithm, k2.RawKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := x509.ParsePKIXPublicKey(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
edKey, ok := parsed.(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return ErrInvalidPublicKeyType
|
||||
}
|
||||
|
||||
*k = SPKIPublicKey(edKey)
|
||||
*k = UserPublicKey(*k2)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k UserPublicKey) MarshalJSON() ([]byte, error) {
|
||||
type t UserPublicKey
|
||||
k2 := t(k)
|
||||
|
||||
var err error
|
||||
if k2.RawKey, err = k2.Key.MarshalJSON(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(k2)
|
||||
}
|
||||
|
||||
// SPKIPublicKey is a type that represents a [ed25519.PublicKey] in the SPKI
|
||||
// format.
|
||||
type SPKIPublicKey struct {
|
||||
Key any
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func unmarshalSPKIPubKey(algorithm string, raw []byte) (*SPKIPublicKey, error) {
|
||||
rawStr := ""
|
||||
if err := json.Unmarshal(raw, &rawStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := base64.StdEncoding.DecodeString(rawStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewSPKIPubKey(algorithm, raw)
|
||||
}
|
||||
|
||||
// NewSPKIPubKey decodes the public key from a base64 encoded string and then unmarshals it from the SPKI form.
|
||||
func NewSPKIPubKey(algorithm string, raw []byte) (*SPKIPublicKey, error) {
|
||||
parsed, err := x509.ParsePKIXPublicKey(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SPKIPublicKey{
|
||||
Key: parsed,
|
||||
Algorithm: algorithm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarshalJSON marshals the SPKI-encoded public key to a base64 encoded string.
|
||||
func (k SPKIPublicKey) MarshalJSON() ([]byte, error) {
|
||||
raw, err := x509.MarshalPKIXPublicKey(ed25519.PublicKey(k))
|
||||
raw, err := x509.MarshalPKIXPublicKey(k.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -61,6 +94,6 @@ func (k SPKIPublicKey) MarshalJSON() ([]byte, error) {
|
|||
return json.Marshal(base64.StdEncoding.EncodeToString(raw))
|
||||
}
|
||||
|
||||
func (k SPKIPublicKey) ToStd() ed25519.PublicKey {
|
||||
return ed25519.PublicKey(k)
|
||||
func (k SPKIPublicKey) ToKey() crypto.PublicKey {
|
||||
return k.Key
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,20 @@ import (
|
|||
func TestSPKIPublicKey_UnmarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs="))
|
||||
|
||||
pk := PublicKey{}
|
||||
pk := UserPublicKey{}
|
||||
raw := []byte(`{"public_key":"MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs="}`)
|
||||
if err := json.Unmarshal(raw, &pk); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedPk, ed25519.PublicKey(pk.PublicKey))
|
||||
assert.Equal(t, expectedPk, ed25519.PublicKey(pk.Key))
|
||||
}
|
||||
|
||||
func TestSPKIPublicKey_MarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs=")).(ed25519.PublicKey)
|
||||
|
||||
pk := PublicKey{
|
||||
PublicKey: SPKIPublicKey(expectedPk),
|
||||
pk := UserPublicKey{
|
||||
Key: SPKIPublicKey(expectedPk),
|
||||
}
|
||||
if _, err := json.Marshal(pk); err != nil {
|
||||
t.Error(err)
|
||||
|
|
|
|||
62
pkg/protoretry/client.go
Normal file
62
pkg/protoretry/client.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package protoretry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
base *http.Client
|
||||
}
|
||||
|
||||
func New(base *http.Client) *Client {
|
||||
return &Client{base}
|
||||
}
|
||||
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
if res, err := c.base.Do(req); err == nil {
|
||||
return res, nil
|
||||
} else if !errors.Is(err, syscall.ECONNREFUSED) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.URL.Scheme = "http"
|
||||
return c.base.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GET(ctx context.Context, u *url.URL) ([]byte, *http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.DoReq(req)
|
||||
}
|
||||
|
||||
func (c *Client) POST(ctx context.Context, u *url.URL, reqBody io.Reader) ([]byte, *http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u.String(), reqBody)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return c.DoReq(req)
|
||||
}
|
||||
|
||||
func (c *Client) DoReq(req *http.Request) ([]byte, *http.Response, error) {
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
resBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return resBody, res, nil
|
||||
}
|
||||
|
|
@ -71,15 +71,15 @@ type Client struct {
|
|||
log logr.Logger
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, name string, natsClient *nats.Conn, telemetry *unitel.Telemetry, log logr.Logger) (*Client, error) {
|
||||
func NewClient(ctx context.Context, streamName string, natsClient *nats.Conn, telemetry *unitel.Telemetry, log logr.Logger) (*Client, error) {
|
||||
js, err := jetstream.New(natsClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s, err := js.CreateStream(ctx, jetstream.StreamConfig{
|
||||
Name: name,
|
||||
Subjects: []string{name + ".*"},
|
||||
Name: streamName,
|
||||
Subjects: []string{streamName + ".*"},
|
||||
MaxConsumers: -1,
|
||||
MaxMsgs: -1,
|
||||
Discard: jetstream.DiscardOld,
|
||||
|
|
@ -89,7 +89,7 @@ func NewClient(ctx context.Context, name string, natsClient *nats.Conn, telemetr
|
|||
AllowDirect: true,
|
||||
})
|
||||
if errors.Is(err, nats.ErrStreamNameAlreadyInUse) {
|
||||
s, err = js.Stream(ctx, name)
|
||||
s, err = js.Stream(ctx, streamName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -100,8 +100,8 @@ func NewClient(ctx context.Context, name string, natsClient *nats.Conn, telemetr
|
|||
stopCh := make(chan struct{})
|
||||
|
||||
c := &Client{
|
||||
name: name,
|
||||
subject: name + ".tasks",
|
||||
name: streamName,
|
||||
subject: streamName + ".tasks",
|
||||
|
||||
handlers: map[string][]Handler{},
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ func (c *Client) Submit(ctx context.Context, task Task) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.log.V(1).Info("submitted task", "id", task.ID, "type", task.Type, "sequence", msg.Sequence)
|
||||
c.log.V(2).Info("Submitted task", "id", task.ID, "type", task.Type, "sequence", msg.Sequence)
|
||||
|
||||
s.AddAttribute("messaging.message.id", msg.Sequence)
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ func (c *Client) Submit(ctx context.Context, task Task) error {
|
|||
}
|
||||
|
||||
func (c *Client) RegisterHandler(type_ string, handler Handler) {
|
||||
c.log.V(2).Info("registering handler", "type", type_)
|
||||
c.log.V(2).Info("Registering handler", "type", type_)
|
||||
|
||||
if _, ok := c.handlers[type_]; !ok {
|
||||
c.handlers[type_] = []Handler{}
|
||||
|
|
@ -161,13 +161,11 @@ func (c *Client) RegisterHandler(type_ string, handler Handler) {
|
|||
c.handlers[type_] = append(c.handlers[type_], handler)
|
||||
}
|
||||
|
||||
func (c *Client) Start(ctx context.Context) error {
|
||||
c.log.Info("starting")
|
||||
func (c *Client) StartConsumer(ctx context.Context, consumerGroup string) error {
|
||||
c.log.Info("Starting consumer")
|
||||
|
||||
sub, err := c.js.CreateConsumer(ctx, c.name, jetstream.ConsumerConfig{
|
||||
// TODO: set name properly
|
||||
Name: "versia-go",
|
||||
Durable: "versia-go",
|
||||
Durable: consumerGroup,
|
||||
DeliverPolicy: jetstream.DeliverAllPolicy,
|
||||
ReplayPolicy: jetstream.ReplayInstantPolicy,
|
||||
AckPolicy: jetstream.AckExplicitPolicy,
|
||||
|
|
@ -191,16 +189,16 @@ func (c *Client) Start(ctx context.Context) error {
|
|||
msg, err := m.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrMsgIteratorClosed) {
|
||||
c.log.Info("stopping")
|
||||
c.log.Info("Stopping")
|
||||
return
|
||||
}
|
||||
|
||||
c.log.Error(err, "failed to get next message")
|
||||
c.log.Error(err, "Failed to get next message")
|
||||
break
|
||||
}
|
||||
|
||||
if err := c.handleTask(ctx, msg); err != nil {
|
||||
c.log.Error(err, "failed to handle task")
|
||||
c.log.Error(err, "Failed to handle task")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -224,7 +222,7 @@ func (c *Client) handleTask(ctx context.Context, msg jetstream.Msg) error {
|
|||
var w taskWrapper
|
||||
if err := json.Unmarshal(data, &w); err != nil {
|
||||
if err := msg.Nak(); err != nil {
|
||||
c.log.Error(err, "failed to nak message")
|
||||
c.log.Error(err, "Failed to nak message")
|
||||
}
|
||||
|
||||
return err
|
||||
|
|
@ -246,21 +244,21 @@ func (c *Client) handleTask(ctx context.Context, msg jetstream.Msg) error {
|
|||
|
||||
handlers, ok := c.handlers[w.Task.Type]
|
||||
if !ok {
|
||||
c.log.V(1).Info("no handler for task", "type", w.Task.Type)
|
||||
c.log.V(2).Info("No handler for task", "type", w.Task.Type)
|
||||
return msg.Nak()
|
||||
}
|
||||
|
||||
var errs CombinedError
|
||||
for _, handler := range handlers {
|
||||
if err := handler(ctx, w.Task); err != nil {
|
||||
c.log.Error(err, "handler failed", "type", w.Task.Type)
|
||||
c.log.Error(err, "Handler failed", "type", w.Task.Type)
|
||||
errs.Errors = append(errs.Errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs.Errors) > 0 {
|
||||
if err := msg.Nak(); err != nil {
|
||||
c.log.Error(err, "failed to nak message")
|
||||
c.log.Error(err, "Failed to nak message")
|
||||
errs.Errors = append(errs.Errors, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
package webfinger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/lysand-org/versia-go/pkg/protoretry"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSyntax = errors.New("must follow the format \"acct:<ID|Username>@<DOMAIN>\"")
|
||||
ErrUserNotFound = errors.New("user could not be found")
|
||||
)
|
||||
|
||||
func ParseResource(res string) (*UserID, error) {
|
||||
|
|
@ -48,7 +54,7 @@ type Response struct {
|
|||
|
||||
type Link struct {
|
||||
Relation string `json:"rel"`
|
||||
Type any `json:"type"`
|
||||
Type string `json:"type"`
|
||||
Link string `json:"href"`
|
||||
}
|
||||
|
||||
|
|
@ -70,3 +76,47 @@ func (u User) WebFingerResource() Response {
|
|||
},
|
||||
}
|
||||
}
|
||||
|
||||
func Discover(c *protoretry.Client, ctx context.Context, baseURI, username string) (*User, error) {
|
||||
u := &User{UserID: UserID{ID: username, Domain: baseURI}}
|
||||
|
||||
body, resp, err := c.GET(ctx, &url.URL{
|
||||
Scheme: "https",
|
||||
Host: u.UserID.Domain,
|
||||
Path: "/.well-known/webfinger",
|
||||
RawQuery: url.Values{"resource": []string{"acct:" + u.String()}}.Encode(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, ErrUserNotFound
|
||||
} else if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var respBody Response
|
||||
if err := json.Unmarshal(body, &respBody); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if respBody.Error != nil {
|
||||
return nil, fmt.Errorf("webfinger error: %s", *respBody.Error)
|
||||
}
|
||||
|
||||
for _, link := range respBody.Links {
|
||||
if link.Relation == "self" {
|
||||
if u.URI, err = url.Parse(link.Link); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if link.Relation == "avatar" {
|
||||
u.AvatarMIMEType = link.Type
|
||||
if u.Avatar, err = url.Parse(link.Link); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue