commit dc892f6ac42aba5361aa3ea1d95750f1515e9861 Author: Dustin Stiles <duwstiles@pm.me> Date: Fri Mar 7 21:05:37 2025 -0500 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c066c3 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# DSFX diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f140e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module koti.casa/numenor-labs/dsfx + +go 1.24.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..8b03507 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "log" +) + +func main() { + log.Println("Hello, World!") +} diff --git a/pkg/dcrypto/aead.go b/pkg/dcrypto/aead.go new file mode 100644 index 0000000..ed799d0 --- /dev/null +++ b/pkg/dcrypto/aead.go @@ -0,0 +1,58 @@ +package dcrypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "io" +) + +// Encrypt uses AES-GCM to encrypt the given plaintext with the given key. +func Encrypt(key, plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) + + return ciphertext, nil +} + +// Decrypt uses AES-GCM to decrypt the given ciphertext with the given key. +func Decrypt(key, ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, errors.New("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} diff --git a/pkg/dcrypto/ecdh.go b/pkg/dcrypto/ecdh.go new file mode 100644 index 0000000..4286405 --- /dev/null +++ b/pkg/dcrypto/ecdh.go @@ -0,0 +1,31 @@ +package dcrypto + +import ( + "crypto/ecdh" + "crypto/rand" +) + +var ( + // DefaultDHCurve is the default elliptic curve used for signing. + DefaultDHCurve = ecdh.P384 +) + +// GenerateDHKey generates a new ECDH private key for key exchange. +func GenerateDHKey() (*ecdh.PrivateKey, error) { + return DefaultDHCurve().GenerateKey(rand.Reader) +} + +// ComputeDHSecret computes the shared secret from the private key and the public key. +func ComputeDHSecret(priv *ecdh.PrivateKey, pub *ecdh.PublicKey) ([]byte, error) { + return priv.ECDH(pub) +} + +// ExportDHPublicKey exports the public key as a byte slice. +func ExportDHPublicKey(pub *ecdh.PublicKey) ([]byte, error) { + return pub.Bytes(), nil +} + +// ImportDHPublicKey imports the public key from a byte slice. +func ImportDHPublicKey(data []byte) (*ecdh.PublicKey, error) { + return DefaultDHCurve().NewPublicKey(data) +} diff --git a/pkg/dcrypto/ecdsa.go b/pkg/dcrypto/ecdsa.go new file mode 100644 index 0000000..e1996b6 --- /dev/null +++ b/pkg/dcrypto/ecdsa.go @@ -0,0 +1,62 @@ +package dcrypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "math/big" +) + +var ( + // DefaultSigningCurve is the default elliptic curve used for signing. + DefaultSigningCurve = elliptic.P384 +) + +// GenerateSigningKey generates a new ECDSA private key for signing. +func GenerateSigningKey() (*ecdsa.PrivateKey, error) { + return ecdsa.GenerateKey(DefaultSigningCurve(), rand.Reader) +} + +// Sign signs the data with the private key. +func Sign(priv *ecdsa.PrivateKey, data []byte) ([]byte, error) { + return ecdsa.SignASN1(rand.Reader, priv, data) +} + +// Verify verifies the signature of the data with the public key. +func Verify(pub *ecdsa.PublicKey, data, signature []byte) bool { + return ecdsa.VerifyASN1(pub, data, signature) +} + +// ExportPublicSigningKey exports the public key as a byte slice. +func ExportPublicSigningKey(pub *ecdsa.PublicKey) ([]byte, error) { + data := struct { + N []byte `json:"n"` + PubX []byte `json:"pub_x"` + PubY []byte `json:"pub_y"` + }{ + N: pub.Curve.Params().N.Bytes(), + PubX: pub.X.Bytes(), + PubY: pub.Y.Bytes(), + } + return json.Marshal(data) +} + +// ImportPublicSigningKey imports the public key from a byte slice. +func ImportPublicSigningKey(pubBytes []byte) (*ecdsa.PublicKey, error) { + var data struct { + N []byte `json:"n"` + PubX []byte `json:"pub_x"` + PubY []byte `json:"pub_y"` + } + if err := json.Unmarshal(pubBytes, &data); err != nil { + return nil, err + } + + params := new(ecdsa.PublicKey) + params.Curve = DefaultSigningCurve() + params.X = new(big.Int).SetBytes(data.PubX) + params.Y = new(big.Int).SetBytes(data.PubY) + + return params, nil +} diff --git a/pkg/dcrypto/hkdf.go b/pkg/dcrypto/hkdf.go new file mode 100644 index 0000000..909cb11 --- /dev/null +++ b/pkg/dcrypto/hkdf.go @@ -0,0 +1,11 @@ +package dcrypto + +import ( + "crypto/hkdf" + "crypto/sha256" +) + +// Key creates a key from the given secret and salt using HKDF. +func Key(secret, salt []byte) ([]byte, error) { + return hkdf.Key(sha256.New, secret, salt, "", 32) +} diff --git a/pkg/dcrypto/rand.go b/pkg/dcrypto/rand.go new file mode 100644 index 0000000..b72b239 --- /dev/null +++ b/pkg/dcrypto/rand.go @@ -0,0 +1,12 @@ +package dcrypto + +import "crypto/rand" + +// Challenge generates a random challenge of n bytes. +func Challenge(n int) []byte { + challenge := make([]byte, n) + if _, err := rand.Read(challenge); err != nil || len(challenge) != n { + return nil + } + return challenge +} diff --git a/pkg/encoding/vwb/vwb.go b/pkg/encoding/vwb/vwb.go new file mode 100644 index 0000000..311baeb --- /dev/null +++ b/pkg/encoding/vwb/vwb.go @@ -0,0 +1,55 @@ +// Package vwb provides utilities for working with variable-width byte slices, +// usually in the context of network requests. +package vwb + +import ( + "fmt" + "io" +) + +func debugf(msg string, args ...any) { + fmt.Printf("(encoding/vwb): "+msg, args...) +} + +// Encode ... +func Encode(w io.Writer, input []byte) error { + debugf("encoding %d bytes\n", len(input)) + buf := make([]byte, 0, len(input)+2) + + // Write the length of the input + buf = append(buf, byte(len(input)>>8), byte(len(input))) + // Write the input + buf = append(buf, input...) + + debugf("writing %d bytes\n", len(buf)) + // Write the buffer to the writer + if _, err := w.Write(buf); err != nil { + return err + } + + return nil +} + +// Decode ... +func Decode(r io.Reader) ([]byte, error) { + debugf("beginning decode\n") + var lenbuf [2]byte + // Read the length of the input + if _, err := io.ReadFull(r, lenbuf[:]); err != nil { + return nil, err + } + + debugf("read %d bytes for length\n", lenbuf) + // Calculate the length of the input + length := int(lenbuf[0])<<8 | int(lenbuf[1]) + + debugf("reading %d byte sized message\n", length) + // Read the input + data := make([]byte, length) + if _, err := io.ReadFull(r, data); err != nil { + return nil, err + } + + debugf("done\n") + return data, nil +} diff --git a/pkg/handshake/handshake.go b/pkg/handshake/handshake.go new file mode 100644 index 0000000..375fffe --- /dev/null +++ b/pkg/handshake/handshake.go @@ -0,0 +1,347 @@ +package handshake + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + + "koti.casa/numenor-labs/dsfx/pkg/dcrypto" + "koti.casa/numenor-labs/dsfx/pkg/encoding/vwb" +) + +func debugf(msg string, args ...any) { + fmt.Printf("(debug): "+msg, args...) +} + +// An Actor represents an entity that can participate in the handshake process. +// In this model, there are two actors: Alice and Bob. Alice represents the +// client role and Bob represents the server role. Each actor has a ecdsa private +// key used for identification, and a tcp address that they are reachable at. +type Actor interface { + // Identity represents the identity of the actor. It is used to sign messages + // on behalf of the actor during the handshake process. The actor may choose + // to use a well known key here in order to be recognized by other actors. + // It is also valid to return an ephemeral key here if the actor wishes to + // remain anonymous with the remote actor. + IdentityKey() *ecdsa.PrivateKey + + // Address returns the address of the actor. This is used when listening for + // incoming connections, and when dialing out to other actors. + Address() *net.TCPAddr +} + +// State represents the state of the handshake process. It should be passed to +// and from the various functions in the handshake process. +type State struct { + EphemeralKey *ecdh.PrivateKey +} + +// InitiateHandshake initiates the handshake process between the given actor +// and the remote actor. +func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) ([]byte, error) { + // ------------------------------------------------------------------------ + // Step 1: Ephemeral Key Exchange To Server + + debugf("client: creating dh key\n") + // Create a new ECDH private key for the actor. + ourDHKey, err := dcrypto.GenerateDHKey() + if err != nil { + return nil, err + } + + debugf("client: exporting dh key\n") + // Export the public key of the actor's ECDH private key. + ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey()) + if err != nil { + return nil, err + } + + debugf("client: sending dh key\n") + // Write the actor's public key to the connection. + err = vwb.Encode(conn, ourDHKeyRaw) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 2: Ephemeral Key Exchange From Server + + debugf("client: waiting for server's dh key\n") + // Read the remote actor's public key from the connection. + remoteDHKeyRaw, err := vwb.Decode(conn) + if err != nil { + return nil, err + } + + debugf("client: importing server's dh key\n") + // Import the remote actor's public key. + remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyRaw) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 3: Client Authentication + + debugf("client: exporting public signing key\n") + // Export the public key of the actor's signing key. + ourPublicKeyRaw, err := dcrypto.ExportPublicSigningKey(&actor.IdentityKey().PublicKey) + if err != nil { + return nil, err + } + + debugf("client: exporting remote public signing key\n") + remotePublicKeyRaw, err := dcrypto.ExportPublicSigningKey(rpub) + if err != nil { + return nil, err + } + + debugf("client: creating authentication message\n") + // Construct the message that will be signed by the client. + // This message is formatted as follows: + // rlt + sha256(ae + be) + // This binds both the client and server's long term public keys to the + // ephemeral keys that were exchanged in the previous step. This creates a + // verifiable link between both parties and the ephemeral keys used to + // establish the shared secret. + authMessage, err := buildMessage(ourDHKey.PublicKey(), remoteDHKey) + if err != nil { + return nil, err + } + + debugf("client: signing authentication message\n") + // Sign the message with the actor's private key. + signature, err := dcrypto.Sign(actor.IdentityKey(), authMessage) + if err != nil { + return nil, err + } + + debugf("client: encoding %d bytes of public key\n", len(ourPublicKeyRaw)) + debugf("client: encoding %d bytes of signature\n", len(signature)) + + plaintext := make([]byte, 0, len(ourPublicKeyRaw)+len(signature)) + plaintext = append(plaintext, ourPublicKeyRaw...) + plaintext = append(plaintext, signature...) + + debugf("client: computing shared secret\n") + // Compute the shared secret between the actor and the remote actor. + sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey) + if err != nil { + return nil, err + } + + debugf("client: deriving key from shared secret\n") + // Derive a key from the shared secret using HKDF. + derivedKey, err := dcrypto.Key(sharedSecret, nil) + if err != nil { + return nil, err + } + + debugf("client: encrypting authentication message\n") + // Encrypt the message with the derived key. + boxedMsg, err := dcrypto.Encrypt(derivedKey, plaintext) + if err != nil { + return nil, err + } + + debugf("client: sending authentication message\n") + // Write the boxed message to the connection. + err = vwb.Encode(conn, boxedMsg) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 4: Server Authentication + + debugf("client: waiting for server's authentication message\n") + // Read the authentication message from the connection. + authMessageRaw, err := vwb.Decode(conn) + if err != nil { + return nil, err + } + + debugf("client: decrypting authentication message\n") + // Decrypt the authentication message with the derived key. + plaintext, err = dcrypto.Decrypt(derivedKey, authMessageRaw) + if err != nil { + return nil, err + } + + debugf("client: importing server's signing key\n") + // The server authentication is just verifying the signature it created of + // the client authentication message. + remotePublicKey, err := dcrypto.ImportPublicSigningKey(remotePublicKeyRaw) + if err != nil { + return nil, err + } + + debugf("client: verifying server's signature\n") + if !dcrypto.Verify(remotePublicKey, authMessage, plaintext) { + return nil, errors.New("failed to verify server's signature") + } + + debugf("client: handshake complete\n") + return sharedSecret, nil +} + +// AcceptHandshake accepts a handshake from the given actor and connection. It +// returns the shared secret between the actor and the remote actor. +func AcceptHandshake(actor Actor, conn io.ReadWriter) ([]byte, error) { + // ------------------------------------------------------------------------ + // Step 1: Ephemeral Key Exchange From Client + + debugf("server: starting handshake process\n") + // Read the remote actor's public key from the connection. + remoteDHKeyRaw, err := vwb.Decode(conn) + if err != nil { + return nil, err + } + + debugf("server: importing client's dh key\n") + // Import the remote actor's public key. + remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyRaw) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 2: Ephemeral Key Exchange To Client + + debugf("server: creating dh key\n") + // Create a new ECDH private key for the actor. + ourDHKey, err := dcrypto.GenerateDHKey() + if err != nil { + return nil, err + } + + debugf("server: exporting dh key\n") + // Export the public key of the actor's ECDH private key. + ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey()) + if err != nil { + return nil, err + } + + debugf("server: sending dh key\n") + // Write the actor's public key to the connection. + err = vwb.Encode(conn, ourDHKeyRaw) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 3: Server Authentication + + debugf("server: waiting for client's authentication message\n") + // Read the authentication message from the connection. + authMessageRaw, err := vwb.Decode(conn) + if err != nil { + return nil, err + } + + debugf("server: computing shared secret\n") + // Decrypt the authentication message with the derived key. + sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey) + if err != nil { + return nil, err + } + + debugf("server: deriving key from shared secret\n") + // Derive a key from the shared secret using HKDF. + derivedKey, err := dcrypto.Key(sharedSecret, nil) + if err != nil { + return nil, err + } + + debugf("server: decrypting authentication message\n") + plaintext, err := dcrypto.Decrypt(derivedKey, authMessageRaw) + if err != nil { + return nil, err + } + + clientPublicKeyRaw := plaintext[:222] + signature := plaintext[222:] + + debugf("server: importing client's public signing key\n") + // Verify the client's public key and signature. + clientPublicKey, err := dcrypto.ImportPublicSigningKey(clientPublicKeyRaw) + if err != nil { + return nil, err + } + + debugf("server: verifying client's signature\n") + // Construct the message that was signed by the client. + // This message is formatted as follows: + // rlt + sha256(ae + be) + // This binds both the client and server's long term public keys to the + // ephemeral keys that were exchanged in the previous step. This creates a + // verifiable link between both parties and the ephemeral keys used to + // establish the shared secret. + authMessage, err := buildMessage(remoteDHKey, ourDHKey.PublicKey()) + if err != nil { + return nil, err + } + + if !dcrypto.Verify(clientPublicKey, authMessage, signature) { + return nil, errors.New("failed to verify client's signature") + } + + debugf("server: creating authentication message\n") + // Now we need to sign the authentication message with the server's private + // key. This will be sent back to the client in the next step to authenticate + // the server to the client. + serverSignature, err := dcrypto.Sign(actor.IdentityKey(), authMessage) + if err != nil { + return nil, err + } + + boxedMsg, err := dcrypto.Encrypt(derivedKey, serverSignature) + if err != nil { + return nil, err + } + + debugf("server: sending authentication message\n") + // Send the server's signature back to the client. + err = vwb.Encode(conn, boxedMsg) + if err != nil { + return nil, err + } + + // ------------------------------------------------------------------------ + // Step 4: Client Authentication + + debugf("server: handshake complete\n") + return sharedSecret, nil +} + +func buildMessage(clientPubKey *ecdh.PublicKey, serverPubKey *ecdh.PublicKey) ([]byte, error) { + clientPubKeyRaw, err := dcrypto.ExportDHPublicKey(clientPubKey) + if err != nil { + return nil, err + } + serverPubKeyRaw, err := dcrypto.ExportDHPublicKey(serverPubKey) + if err != nil { + return nil, err + } + // Construct the message that will be signed by the client. + // This message is formatted as follows: + // rlt + sha256(ae + be) + // This binds both the client and server's long term public keys to the + // ephemeral keys that were exchanged in the previous step. This creates a + // verifiable link between both parties and the ephemeral keys used to + // establish the shared secret. + message := make([]byte, 0, len(clientPubKeyRaw)+len(serverPubKeyRaw)) + message = append(message, clientPubKeyRaw...) + message = append(message, serverPubKeyRaw...) + + messageChecksum := sha256.Sum256(message) + authMessage := make([]byte, 0, len(serverPubKeyRaw)+sha256.Size) + authMessage = append(authMessage, serverPubKeyRaw...) + authMessage = append(authMessage, messageChecksum[:]...) + + return authMessage, nil +} diff --git a/pkg/handshake/handshake_test.go b/pkg/handshake/handshake_test.go new file mode 100644 index 0000000..f06c082 --- /dev/null +++ b/pkg/handshake/handshake_test.go @@ -0,0 +1,90 @@ +package handshake_test + +import ( + "bytes" + "crypto/ecdsa" + "log" + "net" + "sync" + "testing" + + "koti.casa/numenor-labs/dsfx/pkg/dcrypto" + "koti.casa/numenor-labs/dsfx/pkg/handshake" +) + +func TestHandshake(t *testing.T) { + var wg sync.WaitGroup + + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + alice := newActor() + bob := newActor() + + var aliceSharedSecret []byte + var aliceErr error + var bobSharedSecret []byte + var bobErr error + + wg.Add(2) + go func() { + aliceSharedSecret, aliceErr = handshake.InitiateHandshake(alice, client, &bob.IdentityKey().PublicKey) + wg.Done() + }() + go func() { + bobSharedSecret, bobErr = handshake.AcceptHandshake(bob, server) + wg.Done() + }() + + wg.Wait() + + if aliceErr != nil || bobErr != nil { + if aliceErr != nil { + t.Errorf("alice error: %v", aliceErr) + return + } + t.Errorf("bob error: %v", bobErr) + return + } + + if aliceSharedSecret == nil || bobSharedSecret == nil { + t.Errorf("handshake failed: shared secret is nil") + return + } + if len(aliceSharedSecret) == 0 || len(bobSharedSecret) == 0 { + t.Errorf("handshake failed: shared secret is empty") + } + if !bytes.Equal(aliceSharedSecret, bobSharedSecret) { + t.Errorf("handshake failed: shared secrets do not match") + return + } +} + +// An actor represents an entity that can participate in the handshake process. +type actor struct { + identity *ecdsa.PrivateKey +} + +// newActor creates a new actor with a random identity key. +func newActor() *actor { + key, err := dcrypto.GenerateSigningKey() + if err != nil { + log.Fatal(err) + } + + return &actor{ + identity: key, + } +} + +// IdentityKey ... +func (a *actor) IdentityKey() *ecdsa.PrivateKey { + return a.identity +} + +// Address returns the address of the actor. This is used when listening for +// incoming connections, and when dialing out to other actors. +func (a *actor) Address() *net.TCPAddr { + return nil +} diff --git a/revive.toml b/revive.toml new file mode 100644 index 0000000..08005fe --- /dev/null +++ b/revive.toml @@ -0,0 +1,41 @@ +ignoreGeneratedHeader = false +severity = "warning" +confidence = 0.8 +errorCode = 1 +warningCode = 1 + +[rule.bare-return] +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.dot-imports] +[rule.empty-block] +[rule.empty-lines] +[rule.enforce-map-style] +[rule.enforce-slice-style] +[rule.error-naming] +[rule.error-return] +[rule.error-strings] +[rule.errorf] +[rule.exported] +[rule.filename-format] + # Override the default pattern to forbid .go files with uppercase letters and dashes. + arguments=["^[_a-z][_a-z0-9]*\\.go$"] +[rule.increment-decrement] +[rule.indent-error-flow] +[rule.line-length-limit] + arguments = [200] +# [rule.package-comments] +[rule.range] +[rule.receiver-naming] +[rule.redefines-builtin-id] +[rule.superfluous-else] +[rule.time-naming] +[rule.unexported-naming] +[rule.unexported-return] +[rule.unreachable-code] +[rule.unused-parameter] +[rule.useless-break] +[rule.use-any] +[rule.var-declaration] +[rule.var-naming]