diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25baf1f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: Go Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.25' + + - name: Build Client + run: go build -o ./freon -v ./client + + - name: Build Coordinator + run: go build -o ./freon-server -v ./coordinator + + - name: Test Client + run: cd client && go test -v -short && cd .. + + - name: Test Coordinator + run: cd coordinator && go test -v -short && cd .. diff --git a/README.md b/README.md index 104db82..c6b4e33 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ > FOSS Resists Executive Overreaching Nations +[![Build Status](https://github.com/soatok/freon/actions/workflows/ci.yml/badge.svg)](https://github.com/soatok/freon/actions/workflows/ci.yml) + FREON implements FROST ([RFC 9591](https://www.rfc-editor.org/rfc/rfc9591.html)) to allow geographically distributed teams produce digital signatures. Each share of the signing key is encrypted locally using [age](https://github.com/FiloSottile/age). @@ -44,7 +46,7 @@ go install ```terminal git clone https://github.com/soatok/freon.git cd freon -CGO_ENABLED=1 go build -o coordinator ./coordinator +go build -o coordinator ./coordinator ./coordinator ``` diff --git a/client/go.sum b/client/go.sum index a679e38..539f720 100644 --- a/client/go.sum +++ b/client/go.sum @@ -20,14 +20,11 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/client/internal/crypto_test.go b/client/internal/crypto_test.go new file mode 100644 index 0000000..e5422c6 --- /dev/null +++ b/client/internal/crypto_test.go @@ -0,0 +1,114 @@ +package internal_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "filippo.io/age" + "github.com/soatok/freon/client/internal" +) + +func TestEncryptDecryptShare(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + + recipient := identity.Recipient().String() + share := []byte("test share") + + encrypted, err := internal.EncryptShare(recipient, share) + if err != nil { + t.Fatal(err) + } + + decrypted, err := internal.DecryptShare(encrypted, identity) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(share, decrypted) { + t.Fatal("decrypted share does not match original share") + } +} + +func TestParseAgeIdentityFile(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + + tempDir := t.TempDir() + identityFile := filepath.Join(tempDir, "key.txt") + + f, err := os.Create(identityFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + _, err = f.WriteString(identity.String()) + if err != nil { + t.Fatal(err) + } + f.Close() + + identities, err := internal.ParseAgeIdentityFile(identityFile) + if err != nil { + t.Fatal(err) + } + + if len(identities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(identities)) + } + + parsedIdentity, ok := identities[0].(*age.X25519Identity) + if !ok { + t.Fatal("identity is not of type X25519Identity") + } + + if parsedIdentity.String() != identity.String() { + t.Fatal("parsed identity does not match original identity") + } +} + +func TestDecryptShareFor(t *testing.T) { + identity, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + + recipient := identity.Recipient().String() + share := []byte("test share") + + encrypted, err := internal.EncryptShare(recipient, share) + if err != nil { + t.Fatal(err) + } + + tempDir := t.TempDir() + identityFile := filepath.Join(tempDir, "key.txt") + + f, err := os.Create(identityFile) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + _, err = f.WriteString(identity.String()) + if err != nil { + t.Fatal(err) + } + f.Close() + + decrypted, err := internal.DecryptShareFor(encrypted, identityFile) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(share, decrypted) { + t.Fatal("decrypted share does not match original share") + } +} diff --git a/client/internal/freon.go b/client/internal/freon.go index 19d4bd8..40546be 100644 --- a/client/internal/freon.go +++ b/client/internal/freon.go @@ -277,7 +277,7 @@ func JoinKeyGenCeremony(host, groupID, recipient string) { // Let's build the list of public shares publicShares := make(map[string]string) for index, sh := range public.Shares { - i := uint16ToHexBE(uint16(index)) + i := Uint16ToHexBE(uint16(index)) shh := hex.EncodeToString(sh.BytesEd25519()) publicShares[i] = shh } @@ -405,7 +405,7 @@ func JoinSignCeremony(ceremonyID, host, identityFile string, message []byte) { // Let's deserialize the public shares publicShares := make(map[party.ID]*ristretto.Element, len(publicSharesHex)) for k, v := range publicSharesHex { - p16, err := hexBEToUint16(k) + p16, err := HexBEToUint16(k) if err != nil { fmt.Fprintf(os.Stderr, "%s", err.Error()) os.Exit(1) diff --git a/client/internal/types..go b/client/internal/types.go similarity index 100% rename from client/internal/types..go rename to client/internal/types.go diff --git a/client/internal/util.go b/client/internal/util.go index d990091..ad87534 100644 --- a/client/internal/util.go +++ b/client/internal/util.go @@ -20,12 +20,12 @@ func HashMessageForSanity(data []byte, groupID string) string { return hex.EncodeToString(mac.Sum(nil)) } -func uint16ToHexBE(n uint16) string { +func Uint16ToHexBE(n uint16) string { bytes := []byte{byte(n >> 8), byte(n)} return hex.EncodeToString(bytes) } -func hexBEToUint16(s string) (uint16, error) { +func HexBEToUint16(s string) (uint16, error) { bytes, err := hex.DecodeString(s) if err != nil { return 0, err diff --git a/client/internal/util_test.go b/client/internal/util_test.go index 5b1501b..19afe0e 100644 --- a/client/internal/util_test.go +++ b/client/internal/util_test.go @@ -113,3 +113,21 @@ func TestPartyToUint16(t *testing.T) { slice := internal.PartyToUint16(party) assert.Equal(t, []uint16{5, 6, 7}, slice) } + +func TestHexBEToUint16(t *testing.T) { + val, err := internal.HexBEToUint16("0100") + assert.NoError(t, err) + assert.Equal(t, uint16(256), val) + + val, err = internal.HexBEToUint16("ffff") + assert.NoError(t, err) + assert.Equal(t, uint16(65535), val) +} + +func TestUint16ToHexBE(t *testing.T) { + hex := internal.Uint16ToHexBE(256) + assert.Equal(t, "0100", hex) + + hex = internal.Uint16ToHexBE(65535) + assert.Equal(t, "ffff", hex) +} diff --git a/client/main.go b/client/main.go index 9530873..a2151a5 100644 --- a/client/main.go +++ b/client/main.go @@ -360,7 +360,6 @@ func FreonSignList(args []string) { fs.Usage() os.Exit(1) } - internal.ListSign(*host, *groupID, *limit, *offset) } diff --git a/coordinator/go.mod b/coordinator/go.mod index 572640c..d31e9bf 100644 --- a/coordinator/go.mod +++ b/coordinator/go.mod @@ -6,8 +6,19 @@ require github.com/taurusgroup/frost-ed25519 v0.0.0-20210707140332-5abc84a4dba7 require github.com/alexedwards/scs/v2 v2.9.0 -require github.com/mattn/go-sqlite3 v1.14.32 +require ( + github.com/ncruces/go-sqlite3 v0.28.0 + github.com/stretchr/testify v1.10.0 +) -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ncruces/julianday v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect + golang.org/x/sys v0.35.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) replace github.com/taurusgroup/frost-ed25519 => github.com/soatok/frost-ed25519 v0.0.0-20250805104728-ae78c7826e4b diff --git a/coordinator/go.sum b/coordinator/go.sum index 95adcae..74c28ac 100644 --- a/coordinator/go.sum +++ b/coordinator/go.sum @@ -5,10 +5,12 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY= -github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-sqlite3 v0.28.0 h1:AQVTUPgfamONl09LS+4rGFbHmLKM8/QrJJJi1UukjEQ= +github.com/ncruces/go-sqlite3 v0.28.0/go.mod h1:WqvLhYwtEiZzg1H8BIeahUv/DxbmR+3xG5jDHDiBAGk= +github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= +github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/soatok/frost-ed25519 v0.0.0-20250805104728-ae78c7826e4b h1:BmCLB7/3z4mYken+4TYyJ5n+/VVSjE+WFUYi1P4IHUo= @@ -22,6 +24,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/coordinator/internal/config.go b/coordinator/internal/config.go index 5e3a525..5feb15f 100644 --- a/coordinator/internal/config.go +++ b/coordinator/internal/config.go @@ -22,13 +22,13 @@ func getConfigFile() (string, error) { // Default user config func NewServerConfig() (CoordinatorConfig, error) { - config := CoordinatorConfig{} + config := CoordinatorConfig{ + Hostname: "localhost:8462", + Database: "./database.sqlite", + } err := config.Save() if err != nil { - return CoordinatorConfig{ - Hostname: "localhost:8462", - Database: "./database.sqlite", - }, err + return CoordinatorConfig{}, err } return config, nil } diff --git a/coordinator/internal/config_test.go b/coordinator/internal/config_test.go new file mode 100644 index 0000000..cc449be --- /dev/null +++ b/coordinator/internal/config_test.go @@ -0,0 +1,47 @@ +package internal_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/soatok/freon/coordinator/internal" + "github.com/stretchr/testify/assert" +) + +func TestServerConfig(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + // Windows uses this environment variable instead + t.Setenv("USERPROFILE", tempDir) + os.Mkdir(tempDir, 0700) + + // Test NewServerConfig + config, err := internal.NewServerConfig() + assert.NoError(t, err) + assert.Equal(t, config.Hostname, "localhost:8462") + assert.Equal(t, config.Database, "./database.sqlite") + + if err := config.Save(); err != nil { + panic(err) + } + + // Check if the file was created + configPath := filepath.Join(tempDir, ".freon-coordinator.json") + _, err = os.Stat(configPath) + assert.NoError(t, err) + + // Test LoadServerConfig + loadedConfig, err := internal.LoadServerConfig() + assert.NoError(t, err) + assert.Equal(t, config, loadedConfig) + + // Test Save + loadedConfig.Hostname = "example.com:1234" + err = loadedConfig.Save() + assert.NoError(t, err) + + savedConfig, err := internal.LoadServerConfig() + assert.NoError(t, err) + assert.Equal(t, loadedConfig, savedConfig) +} diff --git a/coordinator/internal/database.go b/coordinator/internal/database.go index bd420e6..03308ed 100644 --- a/coordinator/internal/database.go +++ b/coordinator/internal/database.go @@ -19,8 +19,7 @@ func DbEnsureTablesExist(db *sql.DB) error { id INTEGER PRIMARY KEY AUTOINCREMENT, groupid INTEGER REFERENCES keygroups(id), uid TEXT NOT NULL, - partyid INTEGER, - state TEXT + partyid INTEGER ); CREATE TABLE IF NOT EXISTS ceremonies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -35,8 +34,7 @@ func DbEnsureTablesExist(db *sql.DB) error { CREATE TABLE IF NOT EXISTS players ( id INTEGER PRIMARY KEY AUTOINCREMENT, ceremonyid INTEGER REFERENCES ceremonies(id), - participantid INTEGER REFERENCES participants(id), - state TEXT + participantid INTEGER REFERENCES participants(id) ); CREATE TABLE IF NOT EXISTS keygenmsg ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -106,16 +104,17 @@ func GetGroupByID(db *sql.DB, groupID int64) (FreonGroup, error) { } defer stmt.Close() + var id int64 var uid string var threshold uint16 var participants uint16 var publicKey *string - err = stmt.QueryRow(groupID).Scan(&uid, &threshold, &participants, &publicKey) + err = stmt.QueryRow(groupID).Scan(&id, &uid, &threshold, &participants, &publicKey) if err != nil { return FreonGroup{}, err } return FreonGroup{ - DbId: groupID, + DbId: id, Uid: uid, Participants: participants, Threshold: threshold, @@ -130,11 +129,10 @@ func GetGroupParticipants(db *sql.DB, groupUid string) ([]FreonParticipant, erro p.id, g.id AS groupid, p.uid, - p.partyid, - p.state + p.partyid FROM keygroups g JOIN participants p ON p.groupid = g.id - WHERE group.uid = ? + WHERE g.uid = ? `) if err != nil { return nil, err @@ -152,12 +150,7 @@ func GetGroupParticipants(db *sql.DB, groupUid string) ([]FreonParticipant, erro var groupId int64 var uid string var partyid uint16 - var stateHex string - if err := rows.Scan(&dbId, &groupId, &uid, &partyid, &stateHex); err != nil { - return nil, err - } - state, err := hex.DecodeString(stateHex) - if err != nil { + if err := rows.Scan(&dbId, &groupId, &uid, &partyid); err != nil { return nil, err } p := FreonParticipant{ @@ -165,7 +158,6 @@ func GetGroupParticipants(db *sql.DB, groupUid string) ([]FreonParticipant, erro GroupID: groupId, Uid: uid, PartyID: partyid, - State: state, } participants = append(participants, p) } @@ -185,7 +177,7 @@ func GetParticipantID(db *sql.DB, groupUid string, myPartyID uint16) (int64, err defer stmt.Close() var id int64 - err = stmt.QueryRow(groupUid).Scan(&id) + err = stmt.QueryRow(groupUid, myPartyID).Scan(&id) if err != nil { return 0, err } @@ -194,7 +186,8 @@ func GetParticipantID(db *sql.DB, groupUid string, myPartyID uint16) (int64, err func GetCeremonyData(db *sql.DB, ceremonyID string) (FreonCeremonies, error) { stmt, err := db.Prepare(`SELECT - id, groupid, active, hash, signature, openssh, opensshnamespace FROM ceremonies + id, groupid, active, hash, signature, openssh, opensshnamespace + FROM ceremonies WHERE uid = ?`) if err != nil { return FreonCeremonies{}, err @@ -207,8 +200,8 @@ func GetCeremonyData(db *sql.DB, ceremonyID string) (FreonCeremonies, error) { var hash string var signature *string var openssh bool - var opensshnamespace string - err = stmt.QueryRow(ceremonyID).Scan(&id, &groupid, &active, &signature, &openssh, &opensshnamespace) + var opensshnamespace *string + err = stmt.QueryRow(ceremonyID).Scan(&id, &groupid, &active, &hash, &signature, &openssh, &opensshnamespace) if err != nil { return FreonCeremonies{}, err } @@ -246,20 +239,26 @@ func GetRecentCeremonies(db *sql.DB, groupID string, limit, offset int64) ([]Fre for rows.Next() { var ceremonyID string var hash string - var signature string + var signature *string var openssh bool - var opensshnamespace string + var opensshnamespace *string var active bool if err := rows.Scan(&ceremonyID, &hash, &signature, &openssh, &opensshnamespace, &active); err != nil { return nil, err } + var ns string + if opensshnamespace == nil { + ns = "" + } else { + ns = *opensshnamespace + } row := FreonCeremonySummary{ Uid: ceremonyID, Active: active, Hash: hash, - Signature: &signature, + Signature: signature, OpenSSH: openssh, - OpenSSHNamespace: opensshnamespace, + OpenSSHNamespace: ns, } results = append(results, row) } @@ -272,7 +271,6 @@ func GetCeremonyPlayers(db *sql.DB, ceremonyID string) ([]FreonPlayers, error) { x.id, x.ceremonyid, x.participantid, - x.state, p.partyid FROM players x JOIN participants p ON x.participantid = p.id @@ -293,20 +291,14 @@ func GetCeremonyPlayers(db *sql.DB, ceremonyID string) ([]FreonPlayers, error) { var dbId int64 var ceremonyId int64 var participantId int64 - var stateHex string var partyId uint16 - if err := rows.Scan(&dbId, &ceremonyId, &participantId, &stateHex, &partyId); err != nil { - return nil, err - } - state, err := hex.DecodeString(stateHex) - if err != nil { + if err := rows.Scan(&dbId, &ceremonyId, &participantId, &partyId); err != nil { return nil, err } p := FreonPlayers{ DbId: dbId, CeremonyID: ceremonyId, ParticipantID: participantId, - State: state, PartyID: partyId, } players = append(players, p) @@ -368,7 +360,7 @@ func GetSignMessagesSince(db *sql.DB, ceremonyUid string, lastSeen int64) ([]Fre msg.sender, msg.message FROM ceremonies c - JOIN signmsg msg ON msg.groupid = g.id + JOIN signmsg msg ON msg.ceremonyid = c.id JOIN participants p ON msg.sender = p.id WHERE c.uid = ? AND msg.id > ? `) @@ -439,12 +431,11 @@ func InsertCeremony(db *sql.DB, c FreonCeremonies) (int64, error) { } func InsertParticipant(db *sql.DB, p FreonParticipant) (int64, error) { - stmt, err := db.Prepare(`INSERT INTO participants (groupid, uid, partyid, state) VALUES (?, ?, ?, ?)`) + stmt, err := db.Prepare(`INSERT INTO participants (groupid, uid, partyid) VALUES (?, ?, ?)`) if err != nil { return 0, err } - stateHex := hex.EncodeToString(p.State) - res, err := stmt.Exec(p.GroupID, p.Uid, p.PartyID, stateHex) + res, err := stmt.Exec(p.GroupID, p.Uid, p.PartyID) if err != nil { return 0, err } @@ -456,12 +447,11 @@ func InsertParticipant(db *sql.DB, p FreonParticipant) (int64, error) { } func InsertPlayer(db *sql.DB, p FreonPlayers) (int64, error) { - stmt, err := db.Prepare(`INSERT INTO players (ceremonyid, participantid, state) VALUES (?, ?, ?)`) + stmt, err := db.Prepare(`INSERT INTO players (ceremonyid, participantid) VALUES (?, ?)`) if err != nil { return 0, err } - stateHex := hex.EncodeToString(p.State) - res, err := stmt.Exec(p.CeremonyID, p.ParticipantID, stateHex) + res, err := stmt.Exec(p.CeremonyID, p.ParticipantID) if err != nil { return 0, err } @@ -506,20 +496,6 @@ func InsertSignMessage(db *sql.DB, m FreonSignMessage) (int64, error) { return id, nil } -// Allows the "state" of the participants in a key group to be updated -func UpdateParticipantState(db *sql.DB, p FreonParticipant) error { - stmt, err := db.Prepare(`UPDATE participants SET state = ? WHERE id = ?`) - if err != nil { - return err - } - stateHex := hex.EncodeToString(p.State) - _, err = stmt.Exec(stateHex, p.DbId) - if err != nil { - return err - } - return nil -} - func FinalizeGroup(db *sql.DB, g FreonGroup) error { if g.PublicKey == nil { return errors.New("public key is not stored in FreonGroup struct") diff --git a/coordinator/internal/database_test.go b/coordinator/internal/database_test.go new file mode 100644 index 0000000..b931863 --- /dev/null +++ b/coordinator/internal/database_test.go @@ -0,0 +1,214 @@ +package internal_test + +import ( + "database/sql" + "testing" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + "github.com/soatok/freon/coordinator/internal" + "github.com/stretchr/testify/assert" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", "file::memory:?cache=shared") + assert.NoError(t, err) + t.Cleanup(func() { + db.Close() + }) + return db +} + +func TestDbEnsureTablesExist(t *testing.T) { + db := setupTestDB(t) + err := internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + + // Check if tables were created + tables := []string{ + "keygroups", + "participants", + "ceremonies", + "players", + "keygenmsg", + "signmsg", + } + for _, table := range tables { + var name string + err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name) + assert.NoError(t, err) + assert.Equal(t, table, name) + } +} + +func TestParticipantFunctions(t *testing.T) { + db := setupTestDB(t) + err := internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + + // Insert a group + group := internal.FreonGroup{ + Uid: "test_group", + Participants: 3, + Threshold: 2, + } + gid, err := internal.InsertGroup(db, group) + assert.NoError(t, err) + + // Insert a participant + p := internal.FreonParticipant{ + GroupID: gid, + Uid: "test_participant", + PartyID: 1, + } + pid, err := internal.InsertParticipant(db, p) + assert.NoError(t, err) + assert.NotZero(t, pid) + p.DbId = pid + + // GetGroupParticipants + participants, err := internal.GetGroupParticipants(db, "test_group") + assert.NoError(t, err) + assert.Len(t, participants, 1) + assert.Equal(t, p.Uid, participants[0].Uid) + + // GetParticipantID + participantID, err := internal.GetParticipantID(db, "test_group", 1) + assert.NoError(t, err) + assert.Equal(t, pid, participantID) +} + +func TestCeremonyAndMessageFunctions(t *testing.T) { + db := setupTestDB(t) + err := internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + + // Insert a group and participant + group := internal.FreonGroup{Uid: "g", Participants: 2, Threshold: 2} + gid, err := internal.InsertGroup(db, group) + assert.NoError(t, err) + p := internal.FreonParticipant{GroupID: gid, Uid: "p", PartyID: 1} + pid, err := internal.InsertParticipant(db, p) + assert.NoError(t, err) + + // Insert a ceremony + c := internal.FreonCeremonies{ + GroupID: gid, + Uid: "c", + Active: true, + Hash: "hash", + } + cid, err := internal.InsertCeremony(db, c) + assert.NoError(t, err) + c.DbId = cid + + // GetCeremonyData + cData, err := internal.GetCeremonyData(db, "c") + assert.NoError(t, err) + assert.Equal(t, c.Uid, cData.Uid) + + // Insert a player + player := internal.FreonPlayers{ + CeremonyID: cid, + ParticipantID: pid, + } + _, err = internal.InsertPlayer(db, player) + assert.NoError(t, err) + + // GetCeremonyPlayers + players, err := internal.GetCeremonyPlayers(db, "c") + assert.NoError(t, err) + assert.Len(t, players, 1) + assert.Equal(t, pid, players[0].ParticipantID) + + // GetRecentCeremonies + recent, err := internal.GetRecentCeremonies(db, "g", 10, 0) + assert.NoError(t, err) + assert.Len(t, recent, 1) + assert.Equal(t, "c", recent[0].Uid) + + // FinalizeSignature + err = internal.FinalizeSignature(db, c, "sig") + assert.NoError(t, err) + cData, err = internal.GetCeremonyData(db, "c") + assert.NoError(t, err) + assert.False(t, cData.Active) + assert.Equal(t, "sig", *cData.Signature) + + // InsertKeygenMessage + km := internal.FreonKeygenMessage{ + GroupID: gid, + Sender: pid, + Message: []byte("keygen message"), + } + kmid, err := internal.InsertKeygenMessage(db, km) + assert.NoError(t, err) + assert.NotZero(t, kmid) + + // GetKeygenMessagesSince + kms, err := internal.GetKeygenMessagesSince(db, "g", 0) + assert.NoError(t, err) + assert.Len(t, kms, 1) + assert.Equal(t, km.Message, kms[0].Message) + + // InsertSignMessage + sm := internal.FreonSignMessage{ + CeremonyID: cid, + Sender: pid, + Message: []byte("sign message"), + } + smid, err := internal.InsertSignMessage(db, sm) + assert.NoError(t, err) + assert.NotZero(t, smid) + + // GetSignMessagesSince + sms, err := internal.GetSignMessagesSince(db, "c", 0) + assert.NoError(t, err) + assert.Len(t, sms, 1) + assert.Equal(t, sm.Message, sms[0].Message) +} + +func TestGroupFunctions(t *testing.T) { + db := setupTestDB(t) + err := internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + + // Insert a group + group := internal.FreonGroup{ + Uid: "test_group", + Participants: 3, + Threshold: 2, + } + id, err := internal.InsertGroup(db, group) + assert.NoError(t, err) + assert.NotZero(t, id) + group.DbId = id + + // GetGroupRowId + rowId, err := internal.GetGroupRowId(db, "test_group") + assert.NoError(t, err) + assert.Equal(t, id, int64(rowId)) + + // GetGroupData + groupData, err := internal.GetGroupData(db, "test_group") + assert.NoError(t, err) + assert.Equal(t, group.Uid, groupData.Uid) + assert.Equal(t, group.Participants, groupData.Participants) + assert.Equal(t, group.Threshold, groupData.Threshold) + + // GetGroupByID + groupByID, err := internal.GetGroupByID(db, id) + assert.NoError(t, err) + assert.Equal(t, group.Uid, groupByID.Uid) + + // FinalizeGroup + publicKey := "test_public_key" + group.PublicKey = &publicKey + err = internal.FinalizeGroup(db, group) + assert.NoError(t, err) + + finalizedGroup, err := internal.GetGroupData(db, "test_group") + assert.NoError(t, err) + assert.NotNil(t, finalizedGroup.PublicKey) + assert.Equal(t, publicKey, *finalizedGroup.PublicKey) +} diff --git a/coordinator/internal/keygen_test.go b/coordinator/internal/keygen_test.go new file mode 100644 index 0000000..ccb4e9f --- /dev/null +++ b/coordinator/internal/keygen_test.go @@ -0,0 +1,86 @@ +package internal_test + +import ( + "database/sql" + "testing" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + "github.com/soatok/freon/coordinator/internal" + "github.com/stretchr/testify/assert" +) + +func setupTestDBForKeygen(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", "file::memory:?cache=shared") + assert.NoError(t, err) + err = internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + t.Cleanup(func() { + db.Close() + }) + return db +} + +func TestNewKeyGroup(t *testing.T) { + db := setupTestDBForKeygen(t) + uid, err := internal.NewKeyGroup(db, 3, 2) + assert.NoError(t, err) + assert.NotEmpty(t, uid) + + group, err := internal.GetGroupData(db, uid) + assert.NoError(t, err) + assert.Equal(t, uint16(3), group.Participants) + assert.Equal(t, uint16(2), group.Threshold) +} + +func TestAddParticipant(t *testing.T) { + db := setupTestDBForKeygen(t) + uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + + p1, err := internal.AddParticipant(db, uid) + assert.NoError(t, err) + assert.Equal(t, uint16(1), p1.PartyID) + + p2, err := internal.AddParticipant(db, uid) + assert.NoError(t, err) + assert.Equal(t, uint16(2), p2.PartyID) + + // Group is full + _, err = internal.AddParticipant(db, uid) + assert.Error(t, err) +} + +func TestAddKeyGenMessage(t *testing.T) { + db := setupTestDBForKeygen(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + p, err := internal.AddParticipant(db, g_uid) + assert.NoError(t, err) + + msg, err := internal.AddKeyGenMessage(db, g_uid, p.PartyID, []byte("test message")) + assert.NoError(t, err) + assert.NotZero(t, msg.DbId) + + msgs, err := internal.GetKeygenMessagesSince(db, g_uid, 0) + assert.NoError(t, err) + assert.Len(t, msgs, 1) + assert.Equal(t, []byte("test message"), msgs[0].Message) +} + +func TestSetGroupPublicKey(t *testing.T) { + db := setupTestDBForKeygen(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + + err = internal.SetGroupPublicKey(db, g_uid, "test_pk") + assert.NoError(t, err) + + group, err := internal.GetGroupData(db, g_uid) + assert.NoError(t, err) + assert.Equal(t, "test_pk", *group.PublicKey) + + // Cannot set it again + err = internal.SetGroupPublicKey(db, g_uid, "test_pk_2") + assert.Error(t, err) +} diff --git a/coordinator/internal/sign.go b/coordinator/internal/sign.go index e76b099..13c0205 100644 --- a/coordinator/internal/sign.go +++ b/coordinator/internal/sign.go @@ -55,12 +55,23 @@ func JoinSignCeremony(db *sql.DB, ceremonyID, hash string, myPartyID uint16) (in if subtle.ConstantTimeCompare([]byte(ceremonyData.Hash), []byte(hash)) != 1 { return 0, errors.New("hash mismatch") } + + // Now insert the player + insert, err := db.Prepare(`INSERT INTO players (ceremonyid, participantid) VALUES (?, ?)`) + if err != nil { + return 0, err + } + _, err = insert.Exec(ceremonyData.DbId, participantId) + if err != nil { + return 0, err + } return participantId, nil } func PollSignCeremony(db *sql.DB, ceremonyID string, myPartyID uint16) (PollSignResponse, error) { ceremonyData, err := GetCeremonyData(db, ceremonyID) if err != nil { + panic(err) return PollSignResponse{}, err } diff --git a/coordinator/internal/sign_test.go b/coordinator/internal/sign_test.go new file mode 100644 index 0000000..103a35c --- /dev/null +++ b/coordinator/internal/sign_test.go @@ -0,0 +1,116 @@ +package internal_test + +import ( + "database/sql" + "testing" + + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" + "github.com/soatok/freon/coordinator/internal" + "github.com/stretchr/testify/assert" +) + +func setupTestDBForSign(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite3", "file::memory:?cache=shared") + assert.NoError(t, err) + err = internal.DbEnsureTablesExist(db) + assert.NoError(t, err) + t.Cleanup(func() { + db.Close() + }) + return db +} + +func TestNewSignGroup(t *testing.T) { + db := setupTestDBForSign(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + + c_uid, err := internal.NewSignGroup(db, g_uid, "hash", false, "") + assert.NoError(t, err) + assert.NotEmpty(t, c_uid) + + c, err := internal.GetCeremonyData(db, c_uid) + assert.NoError(t, err) + assert.Equal(t, "hash", c.Hash) +} + +func TestJoinSignCeremony(t *testing.T) { + db := setupTestDBForSign(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + p, err := internal.AddParticipant(db, g_uid) + assert.NoError(t, err) + c_uid, err := internal.NewSignGroup(db, g_uid, "hash", false, "") + assert.NoError(t, err) + + pid, err := internal.JoinSignCeremony(db, c_uid, "hash", p.PartyID) + assert.NoError(t, err) + assert.Equal(t, p.DbId, pid) + + // Wrong hash + _, err = internal.JoinSignCeremony(db, c_uid, "wrong_hash", p.PartyID) + assert.Error(t, err) +} + +func TestPollSignCeremony(t *testing.T) { + db := setupTestDBForSign(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + p1, err := internal.AddParticipant(db, g_uid) + assert.NoError(t, err) + p2, err := internal.AddParticipant(db, g_uid) + assert.NoError(t, err) + c_uid, err := internal.NewSignGroup(db, g_uid, "hash", false, "") + assert.NoError(t, err) + _, err = internal.JoinSignCeremony(db, c_uid, "hash", p1.PartyID) + assert.NoError(t, err) + _, err = internal.JoinSignCeremony(db, c_uid, "hash", p2.PartyID) + assert.NoError(t, err) + + poll, err := internal.PollSignCeremony(db, c_uid, p1.PartyID) + assert.NoError(t, err) + assert.Equal(t, g_uid, poll.GroupID) + + // Ensure party 1 sees party 2 + assert.Len(t, poll.OtherParties, 1) + assert.Equal(t, p2.PartyID, poll.OtherParties[0]) +} + +func TestAddSignMessage(t *testing.T) { + db := setupTestDBForSign(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + p, err := internal.AddParticipant(db, g_uid) + assert.NoError(t, err) + c_uid, err := internal.NewSignGroup(db, g_uid, "hash", false, "") + assert.NoError(t, err) + + msg, err := internal.AddSignMessage(db, c_uid, p.PartyID, []byte("test message")) + assert.NoError(t, err) + assert.NotZero(t, msg.DbId) + + msgs, err := internal.GetSignMessagesSince(db, c_uid, 0) + assert.NoError(t, err) + assert.Len(t, msgs, 1) + assert.Equal(t, []byte("test message"), msgs[0].Message) +} + +func TestSetSignature(t *testing.T) { + db := setupTestDBForSign(t) + g_uid, err := internal.NewKeyGroup(db, 2, 2) + assert.NoError(t, err) + c_uid, err := internal.NewSignGroup(db, g_uid, "hash", false, "") + assert.NoError(t, err) + + err = internal.SetSignature(db, c_uid, "sig") + assert.NoError(t, err) + + c, err := internal.GetCeremonyData(db, c_uid) + assert.NoError(t, err) + assert.Equal(t, "sig", *c.Signature) + + // Cannot set it again + err = internal.SetSignature(db, c_uid, "sig2") + assert.Error(t, err) +} diff --git a/coordinator/internal/types.go b/coordinator/internal/types.go index e646353..a6486d0 100644 --- a/coordinator/internal/types.go +++ b/coordinator/internal/types.go @@ -31,7 +31,7 @@ type FreonCeremonies struct { Hash string Signature *string OpenSSH bool - OpenSSHNamespace string + OpenSSHNamespace *string } // For public lists of signing ceremonies @@ -49,7 +49,6 @@ type FreonPlayers struct { CeremonyID int64 ParticipantID int64 PartyID uint16 - State []byte } type FreonSignMessage struct { diff --git a/coordinator/internal/util_test.go b/coordinator/internal/util_test.go new file mode 100644 index 0000000..f8664f5 --- /dev/null +++ b/coordinator/internal/util_test.go @@ -0,0 +1,21 @@ +package internal_test + +import ( + "testing" + + "github.com/soatok/freon/coordinator/internal" + "github.com/stretchr/testify/assert" +) + +func TestUniqueID(t *testing.T) { + id1, err := internal.UniqueID() + assert.NoError(t, err) + assert.Len(t, id1, 48) + + id2, err := internal.UniqueID() + assert.NoError(t, err) + assert.Len(t, id2, 48) + + // These should never be equal + assert.NotEqual(t, id1, id2) +} diff --git a/coordinator/server.go b/coordinator/server.go index 58e0c93..28ecc76 100644 --- a/coordinator/server.go +++ b/coordinator/server.go @@ -10,7 +10,8 @@ import ( "time" "github.com/alexedwards/scs/v2" - _ "github.com/mattn/go-sqlite3" // SQLite driver + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" "github.com/soatok/freon/coordinator/internal" _ "github.com/taurusgroup/frost-ed25519/pkg/frost" ) diff --git a/go.work b/go.work new file mode 100644 index 0000000..f48171d --- /dev/null +++ b/go.work @@ -0,0 +1,6 @@ +go 1.25 + +use ( + ./client + ./coordinator +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..b99a4c3 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,11 @@ +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/ncruces/aa v0.3.0/go.mod h1:ctOw1LVqfuqzqg2S9LlR045bLAiXtaTiPMCL3zzl7Ik= +github.com/ncruces/sort v0.1.5/go.mod h1:obJToO4rYr6VWP0Uw5FYymgYGt3Br4RXcs/JdKaXAPk= +github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +lukechampine.com/adiantum v1.1.1/go.mod h1:LrAYVnTYLnUtE/yMp5bQr0HstAf060YUF8nM0B6+rUw=