refactor!: add missing fields and docs

This commit is contained in:
DevMiner 2024-08-22 23:03:38 +02:00
parent 61891d891a
commit 6e59386f60
73 changed files with 726 additions and 580 deletions

View 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))
}

View 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
}

View 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
View 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}
}
}

View 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)
}

View 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)
}

View 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
}

View 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)
}
}

View 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
}

View 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)}
}
}