mirror of
https://github.com/versia-pub/versia-go.git
synced 2026-03-13 04:29:15 +01:00
refactor!: add missing fields and docs
This commit is contained in:
parent
61891d891a
commit
6e59386f60
73 changed files with 726 additions and 580 deletions
39
pkg/versia/crypto/crypto_test.go
Normal file
39
pkg/versia/crypto/crypto_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFederationClient_ValidateSignatureHeader(t *testing.T) {
|
||||
var (
|
||||
bobURL = &url.URL{Scheme: "https", Host: "bob.com"}
|
||||
|
||||
bobPrivBytes = must(base64.StdEncoding.DecodeString, "MC4CAQAwBQYDK2VwBCIEINOATgmaya61Ha9OEE+DD3RnOEqDaHyQ3yLf5upwskUU")
|
||||
bobPriv = must(x509.ParsePKCS8PrivateKey, bobPrivBytes).(ed25519.PrivateKey)
|
||||
signer = Signer{PrivateKey: bobPriv, UserURL: bobURL}
|
||||
|
||||
bobPubBytes = must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAQ08Z/FJ5f16o8mthLaFZMo4ssn0fJ7c+bipNYm3kId4=")
|
||||
bobPub = must(x509.ParsePKIXPublicKey, bobPubBytes).(ed25519.PublicKey)
|
||||
verifier = Verifier{PublicKey: bobPub}
|
||||
|
||||
method = "POST"
|
||||
nonce = "myrandomnonce"
|
||||
u = &url.URL{Scheme: "https", Host: "bob.com", Path: "/a/b/c", RawQuery: "z=foo&a=bar"}
|
||||
body = []byte("hello")
|
||||
)
|
||||
|
||||
toSign := NewSignatureData(method, nonce, u, hashSHA256(body))
|
||||
assert.Equal(t, `post /a/b/c?z=foo&a=bar myrandomnonce LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=`, toSign.String())
|
||||
|
||||
signed := signer.Sign(*toSign)
|
||||
assert.Equal(t, true, verifier.Verify(method, u, body, signed), "signature verification failed")
|
||||
|
||||
assert.Equal(t, "myrandomnonce", signed.Nonce)
|
||||
assert.Equal(t, bobURL, signed.SignedBy)
|
||||
assert.Equal(t, "datQHNaqJ1jeKzK3UeReUVf+B65JPq5P9LxfqUUJTMv3QNqDu5KawosKoduIRk4/D/A+EKjDhlcw0c7GzUlMCA==", base64.StdEncoding.EncodeToString(signed.Signature))
|
||||
}
|
||||
67
pkg/versia/crypto/federation_headers.go
Normal file
67
pkg/versia/crypto/federation_headers.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// FederationHeaders represents the signature header of the Lysand protocol. For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://versia.pub/signatures#signature-definition
|
||||
type FederationHeaders struct {
|
||||
// SignedBy is the URL to a user
|
||||
SignedBy *url.URL
|
||||
// Nonce is a random string, used to prevent replay attacks
|
||||
Nonce string
|
||||
// Signature is the signature of the request
|
||||
Signature []byte
|
||||
}
|
||||
|
||||
func (f *FederationHeaders) Inject(h http.Header) {
|
||||
h.Set("x-signed-by", f.SignedBy.String())
|
||||
h.Set("x-nonce", f.Nonce)
|
||||
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 == "" {
|
||||
return nil, fmt.Errorf("missing x-signed-by header")
|
||||
}
|
||||
|
||||
u, err := url.Parse(signedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := h.Get("x-nonce")
|
||||
if nonce == "" {
|
||||
return nil, fmt.Errorf("missing x-nonce header")
|
||||
}
|
||||
|
||||
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: signature,
|
||||
}, nil
|
||||
}
|
||||
18
pkg/versia/crypto/federation_headers_test.go
Normal file
18
pkg/versia/crypto/federation_headers_test.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFederationHeaders_String(t *testing.T) {
|
||||
one := SignatureData{
|
||||
RequestMethod: "POST",
|
||||
Nonce: "1234567890",
|
||||
URL: &url.URL{Scheme: "https", Host: "bob.com", Path: "/users/bob", RawQuery: "z=foo&a=bar"},
|
||||
Digest: hashSHA256([]byte("hello")),
|
||||
}
|
||||
|
||||
assert.Equal(t, "post /users/bob?z=foo&a=bar 1234567890 LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=", one.String())
|
||||
}
|
||||
23
pkg/versia/crypto/keys.go
Normal file
23
pkg/versia/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/versia/crypto/sha256.go
Normal file
9
pkg/versia/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)
|
||||
}
|
||||
94
pkg/versia/crypto/signature_data.go
Normal file
94
pkg/versia/crypto/signature_data.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SignatureData is a combination of HTTP method, URL (only url.URL#Path and url.URL#RawQuery are required),
|
||||
// a nonce and the Base64 encoded SHA256 hash of the request body.
|
||||
// For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://versia.pub/signatures
|
||||
type SignatureData struct {
|
||||
// RequestMethod is the *lowercase* HTTP method of the request
|
||||
RequestMethod string
|
||||
|
||||
// Nonce is a random byte array, used to prevent replay attacks
|
||||
Nonce string
|
||||
|
||||
// RawPath is the path of the request, without the query string
|
||||
URL *url.URL
|
||||
|
||||
// Digest is the SHA-256 hash of the request body
|
||||
Digest []byte
|
||||
}
|
||||
|
||||
func NewSignatureData(method, nonce string, u *url.URL, digest []byte) *SignatureData {
|
||||
return &SignatureData{
|
||||
RequestMethod: method,
|
||||
Nonce: nonce,
|
||||
URL: u,
|
||||
Digest: digest,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the payload to sign
|
||||
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))
|
||||
}
|
||||
|
||||
// Validate validate that the SignatureData belongs to the provided public key and matches the provided signature.
|
||||
func (s *SignatureData) Validate(pubKey crypto.PublicKey, signature []byte) bool {
|
||||
data := []byte(s.String())
|
||||
|
||||
verify, err := NewVerify(pubKey)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return verify(data, signature)
|
||||
}
|
||||
|
||||
// Sign signs the SignatureData with the provided private key.
|
||||
func (s *SignatureData) Sign(privKey ed25519.PrivateKey) []byte {
|
||||
return ed25519.Sign(privKey, []byte(s.String()))
|
||||
}
|
||||
|
||||
// Signer is an object, with which requests can be signed with the user's private key.
|
||||
// For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://versia.pub/signatures
|
||||
type Signer struct {
|
||||
PrivateKey ed25519.PrivateKey
|
||||
UserURL *url.URL
|
||||
}
|
||||
|
||||
// Sign signs a signature data and returns the headers to inject into the response.
|
||||
func (s Signer) Sign(signatureData SignatureData) *FederationHeaders {
|
||||
return &FederationHeaders{
|
||||
SignedBy: s.UserURL,
|
||||
Nonce: signatureData.Nonce,
|
||||
Signature: signatureData.Sign(s.PrivateKey),
|
||||
}
|
||||
}
|
||||
|
||||
// Verifier is an object, with which requests can be verified against a user's public key.
|
||||
// For more information, see the [Spec].
|
||||
//
|
||||
// [Spec]: https://versia.pub/signatures
|
||||
type Verifier struct {
|
||||
PublicKey crypto.PublicKey
|
||||
}
|
||||
|
||||
// Verify verifies a request against the public key provided to it duration object creation.
|
||||
func (v Verifier) Verify(method string, u *url.URL, body []byte, fedHeaders *FederationHeaders) bool {
|
||||
return NewSignatureData(method, fedHeaders.Nonce, u, SHA256(body)).
|
||||
Validate(v.PublicKey, fedHeaders.Signature)
|
||||
}
|
||||
56
pkg/versia/crypto/spki_public_key.go
Normal file
56
pkg/versia/crypto/spki_public_key.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// 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(k.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(base64.StdEncoding.EncodeToString(raw))
|
||||
}
|
||||
|
||||
func (k SPKIPublicKey) ToKey() crypto.PublicKey {
|
||||
return k.Key
|
||||
}
|
||||
33
pkg/versia/crypto/spki_public_key_test.go
Normal file
33
pkg/versia/crypto/spki_public_key_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package versiacrypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSPKIPublicKey_UnmarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs="))
|
||||
|
||||
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.Key))
|
||||
}
|
||||
|
||||
func TestSPKIPublicKey_MarshalJSON(t *testing.T) {
|
||||
expectedPk := must(x509.ParsePKIXPublicKey, must(base64.StdEncoding.DecodeString, "MCowBQYDK2VwAyEAgKNt+9eyOXdb7MSrrmHlsFD2H9NGwC+56PjpWD46Tcs=")).(ed25519.PublicKey)
|
||||
|
||||
pk := UserPublicKey{
|
||||
Key: SPKIPublicKey(expectedPk),
|
||||
}
|
||||
if _, err := json.Marshal(pk); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
10
pkg/versia/crypto/utils_test.go
Normal file
10
pkg/versia/crypto/utils_test.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package versiacrypto
|
||||
|
||||
func must[In any, Out any](fn func(In) (Out, error), v In) Out {
|
||||
out, err := fn(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
29
pkg/versia/crypto/verify.go
Normal file
29
pkg/versia/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)}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue