summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml22
-rw-r--r--Makefile12
-rw-r--r--exports/.gitignore3
-rw-r--r--exports/Makefile12
-rw-r--r--exports/exports.go28
-rw-r--r--verify.go129
-rw-r--r--verify_test.go74
-rw-r--r--wrappers/csharp/.gitignore4
-rw-r--r--wrappers/csharp/Discovery.cs135
-rw-r--r--wrappers/csharp/EduVpnCommon.csproj27
-rw-r--r--wrappers/csharp/EduVpnCommon.sln22
-rw-r--r--wrappers/csharp/EduVpnCommonTests/EduVpnCommonTests.csproj19
-rw-r--r--wrappers/csharp/EduVpnCommonTests/VerifyTests.cs72
-rw-r--r--wrappers/csharp/Makefile4
14 files changed, 495 insertions, 68 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b695752..b96901f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,10 +1,10 @@
name: Test
-on: [push, pull_request]
+on: [ push, pull_request ]
jobs:
- test:
- name: Test
+ test-go:
+ name: Test Go
runs-on: ubuntu-latest
steps:
@@ -12,4 +12,18 @@ jobs:
- uses: actions/setup-go@v2
with:
go-version: ^1.15
- - run: go test
+ - run: make test-go
+
+ test-wrappers:
+ name: Test wrappers
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-go@v2
+ with:
+ go-version: ^1.15
+ - uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: 5.0.x
+ - run: make test-wrappers
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b11b774
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,12 @@
+.PHONY: build test test-go test-wrappers
+
+build:
+ $(MAKE) -C exports build
+
+test: test-go test-wrappers
+
+test-go:
+ go test
+
+test-wrappers: build
+ $(MAKE) -C wrappers/csharp test
diff --git a/exports/.gitignore b/exports/.gitignore
new file mode 100644
index 0000000..a95b306
--- /dev/null
+++ b/exports/.gitignore
@@ -0,0 +1,3 @@
+*.dll
+*.so
+*.h
diff --git a/exports/Makefile b/exports/Makefile
new file mode 100644
index 0000000..94781be
--- /dev/null
+++ b/exports/Makefile
@@ -0,0 +1,12 @@
+.PHONY: build
+
+ifeq ($(OS),Windows_NT)
+ lib_suffix := .dll
+else
+ lib_suffix := .so
+endif
+
+build: eduvpn_verify$(lib_suffix)
+
+eduvpn_verify.dll eduvpn_verify.so: exports.go ../verify.go
+ go build -o $@ -buildmode=c-shared $<
diff --git a/exports/exports.go b/exports/exports.go
new file mode 100644
index 0000000..f841eca
--- /dev/null
+++ b/exports/exports.go
@@ -0,0 +1,28 @@
+package main
+
+import "C"
+
+import "eduvpn-common"
+
+// Functions here should not take string parameters, see https://pkg.go.dev/cmd/cgo#hdr-C_references_to_Go
+
+// Verify verifies a signature on a JSON file. See eduvpn_verify.Verify for more details.
+// It returns 0 for a valid signature and a nonzero eduvpn_verify.VerifyErrorCode otherwise.
+//export Verify
+func Verify(signatureFileContent []byte, signedJson []byte, expectedFileName []byte, minSignTime uint64) int {
+ valid, err := eduvpn_verify.Verify(string(signatureFileContent), signedJson, string(expectedFileName), minSignTime)
+ if valid {
+ return 0
+ } else {
+ return int(err.(eduvpn_verify.VerifyError).Code)
+ }
+}
+
+// InsecureTestingSetExtraKey adds an extra allowed key for verification with Verify.
+// ONLY USE FOR TESTING. Not Thread-safe. Do not call in parallel to Verify.
+//export InsecureTestingSetExtraKey
+func InsecureTestingSetExtraKey(keyString []byte) {
+ eduvpn_verify.InsecureTestingSetExtraKey(string(keyString))
+}
+
+func main() { panic("compile with -buildmode=c-shared") }
diff --git a/verify.go b/verify.go
index 89a3d74..5cca4d0 100644
--- a/verify.go
+++ b/verify.go
@@ -3,6 +3,7 @@ package eduvpn_verify
import (
"fmt"
"github.com/jedisct1/go-minisign"
+ "os"
)
// Verify verifies the signature (.minisig file format) on signedJson.
@@ -11,7 +12,7 @@ import (
// minSign must be set to the minimum UNIX timestamp (without milliseconds) for the file version.
// This value should not be smaller than the time on the previous document verified.
//
-// The return value will either be (true, nil) for a valid signature or (false, err) otherwise.
+// The return value will either be (true, nil) for a valid signature or (false, VerifyError) otherwise.
//
// Verify is a wrapper around verifyWithKeys where allowedPublicKeys is set to the list from https://git.sr.ht/~eduvpn/disco.eduvpn.org#public-keys.
func Verify(signatureFileContent string, signedJson []byte, expectedFileName string, minSignTime uint64) (bool, error) {
@@ -19,11 +20,51 @@ func Verify(signatureFileContent string, signedJson []byte, expectedFileName str
"RWRtBSX1alxyGX+Xn3LuZnWUT0w//B6EmTJvgaAxBMYzlQeI+jdrO6KF", // fkooman@tuxed.net, kolla@uninett.no
"RWQKqtqvd0R7rUDp0rWzbtYPA3towPWcLDCl7eY9pBMMI/ohCmrS0WiM", // RoSp
}
+ if extraKey != "" {
+ keyStrs = append(keyStrs, extraKey)
+ _, err := fmt.Fprintf(os.Stderr, "INSECURE TEST MODE ENABLED WITH KEY %q\n", extraKey)
+ if err != nil {
+ panic(err)
+ }
+ }
valid, err := verifyWithKeys(signatureFileContent, signedJson, expectedFileName, minSignTime, keyStrs)
- if err != nil && err.(VerifyError).Code == ErrInvalidPublicKey {
- panic(err) // This should not happen
+ if err != nil {
+ if err.(detailedVerifyError).Code == errInvalidPublicKey {
+ panic(err) // This should not happen unless keyStrs has an invalid key
+ }
+ return valid, err.(detailedVerifyError).ToVerifyError()
}
- return valid, err
+ return valid, nil
+}
+
+// Extra allowed key for testing
+var extraKey = ""
+
+// InsecureTestingSetExtraKey adds an extra allowed key for verification with Verify.
+// ONLY USE FOR TESTING. Probably not thread-safe. Do not call in parallel to Verify.
+func InsecureTestingSetExtraKey(keyString string) {
+ extraKey = keyString
+}
+
+type VerifyErrorCode int
+
+const (
+ ErrUnknownExpectedFileName VerifyErrorCode = iota + 1
+ ErrInvalidSignature
+ ErrInvalidSignatureUnknownKey
+ ErrTooOld
+)
+
+type VerifyError struct {
+ Code VerifyErrorCode
+ Detailed detailedVerifyError
+}
+
+func (err VerifyError) Error() string {
+ return err.Detailed.Error()
+}
+func (err VerifyError) Unwrap() error {
+ return err.Detailed
}
// verifyWithKeys verifies the Minisign signature in signatureFileContent (minisig file format) over the server_list/organization_list JSON in signedJson.
@@ -34,29 +75,28 @@ func Verify(signatureFileContent string, signedJson []byte, expectedFileName str
// The trusted comment is checked to be of the form "timestamp:<timestamp>\tfile:<expectedFileName>", optionally suffixed by something, e.g. "\thashed".
// The signature is checked to have a timestamp with a value of at least minSignTime, which is a UNIX timestamp without milliseconds;
//
-// The return value will either be (true, nil) on success or (false, err) on failure.
+// The return value will either be (true, nil) on success or (false, detailedVerifyError) on failure.
func verifyWithKeys(signatureFileContent string, signedJson []byte, expectedFileName string, minSignTime uint64, allowedPublicKeys []string) (bool, error) {
switch expectedFileName {
case "server_list.json", "organization_list.json":
break
default:
- return false, VerifyError{ErrUnknownExpectedFileName,
- fmt.Sprintf("invalid expected file name (%v)", expectedFileName), nil}
+ return false, detailedVerifyError{errUnknownExpectedFileName, "invalid expected file name", nil}
}
sig, err := minisign.DecodeSignature(signatureFileContent)
if err != nil {
- return false, VerifyError{ErrInvalidSignatureFormat, "invalid signature format", err}
+ return false, detailedVerifyError{errInvalidSignatureFormat, "invalid signature format", err}
}
if sig.SignatureAlgorithm != [2]byte{'E', 'D'} {
- return false, VerifyError{ErrInvalidSignatureAlgorithm, "BLAKE2b-prehashed EdDSA signature required", nil}
+ return false, detailedVerifyError{errInvalidSignatureAlgorithm, "BLAKE2b-prehashed EdDSA signature required", nil}
}
for _, keyStr := range allowedPublicKeys {
key, err := minisign.NewPublicKey(keyStr)
if err != nil {
- return false, VerifyError{ErrInvalidPublicKey, "internal error: could not create public key", err}
+ return false, detailedVerifyError{errInvalidPublicKey, "internal error: could not create public key", err}
}
if sig.KeyId != key.KeyId {
@@ -65,7 +105,7 @@ func verifyWithKeys(signatureFileContent string, signedJson []byte, expectedFile
valid, err := key.Verify(signedJson, sig)
if !valid {
- return false, VerifyError{ErrInvalidSignature, "invalid signature", err}
+ return false, detailedVerifyError{errInvalidSignature, "invalid signature", err}
}
var signTime uint64
@@ -73,49 +113,74 @@ func verifyWithKeys(signatureFileContent string, signedJson []byte, expectedFile
// sigFileName cannot have spaces
_, err = fmt.Sscanf(sig.TrustedComment, "trusted comment: timestamp:%d\tfile:%s", &signTime, &sigFileName)
if err != nil {
- return false, VerifyError{ErrInvalidTrustedComment,
- fmt.Sprintf("failed to interpret trusted comment (%q)", sig.TrustedComment), err}
+ return false, detailedVerifyError{errInvalidTrustedComment, "failed to interpret trusted comment", err}
}
if sigFileName != expectedFileName {
- return false, VerifyError{ErrWrongFileName,
- fmt.Sprintf("signature was on file %q instead of expected %q", sigFileName, expectedFileName), nil}
+ return false, detailedVerifyError{errWrongFileName, "signature was created for wrong file", nil}
}
if signTime < minSignTime {
- return false, VerifyError{ErrTooOld,
- fmt.Sprintf("signature was created at %v < minimum time (%v)", signTime, minSignTime), nil}
+ return false, detailedVerifyError{errTooOld, "signature was created a time earlier than the minimum time specified", nil}
}
return true, nil
}
- return false, VerifyError{ErrWrongKey, "signature was created with an unknown key", nil}
+ return false, detailedVerifyError{errWrongKey, "signature was created with unknown key", nil}
}
-type VerifyErrCode int
+type detailedVerifyErrorCode int
const (
- ErrUnknownExpectedFileName VerifyErrCode = iota
- ErrInvalidSignatureFormat
- ErrInvalidSignatureAlgorithm
- ErrInvalidPublicKey
- ErrInvalidSignature
- ErrInvalidTrustedComment
- ErrWrongFileName
- ErrTooOld
- ErrWrongKey
+ errUnknownExpectedFileName detailedVerifyErrorCode = iota + 1
+ errInvalidSignatureFormat
+ errInvalidSignatureAlgorithm
+ errInvalidPublicKey
+ errInvalidSignature
+ errInvalidTrustedComment
+ errWrongFileName
+ errTooOld
+ errWrongKey
)
-type VerifyError struct {
- Code VerifyErrCode
+func (code detailedVerifyErrorCode) ToVerifyErrorCode() VerifyErrorCode {
+ switch code {
+ case errUnknownExpectedFileName:
+ return ErrUnknownExpectedFileName
+ case errInvalidSignatureFormat:
+ return ErrInvalidSignature
+ case errInvalidSignatureAlgorithm:
+ return ErrInvalidSignature
+ case errInvalidPublicKey:
+ panic("errInvalidPublicKey cannot be converted to VerifyErrorCode")
+ case errInvalidSignature:
+ return ErrInvalidSignature
+ case errInvalidTrustedComment:
+ return ErrInvalidSignature
+ case errWrongFileName:
+ return ErrInvalidSignature
+ case errTooOld:
+ return ErrTooOld
+ case errWrongKey:
+ return ErrInvalidSignatureUnknownKey
+ }
+ panic("invalid detailedVerifyErrorCode")
+}
+
+type detailedVerifyError struct {
+ Code detailedVerifyErrorCode
Message string
Cause error
}
-func (err VerifyError) Error() string {
+func (err detailedVerifyError) Error() string {
return err.Message
}
-func (err VerifyError) Unwrap() error {
+func (err detailedVerifyError) Unwrap() error {
return err.Cause
}
+
+func (err detailedVerifyError) ToVerifyError() VerifyError {
+ return VerifyError{err.Code.ToVerifyErrorCode(), err}
+}
diff --git a/verify_test.go b/verify_test.go
index 3068c96..0689be1 100644
--- a/verify_test.go
+++ b/verify_test.go
@@ -11,15 +11,25 @@ import (
)
const (
- ok VerifyErrCode = -1
- errAny = -2
+ ok = -1
+ errAny = -2
)
-func compareResults(t *testing.T, ret bool, err error, expected VerifyErrCode, call func() string) {
- if (err == nil) != (expected == ok) || err != nil && expected != errAny && err.(VerifyError).Code != expected {
+func compareResults(t *testing.T, ret bool, err error, expected int, call func() string) {
+ getCode := func(err error) int {
+ switch e := err.(type) {
+ case detailedVerifyError:
+ return int(e.Code)
+ case VerifyError:
+ return int(e.Code)
+ }
+ panic(nil)
+ }
+
+ if (err == nil) != (expected == ok) || err != nil && expected != errAny && getCode(err) != expected {
var errMsg string
if err != nil {
- errMsg = fmt.Sprintf("%v %v (cause %v)", err.(VerifyError).Code, err, errors.Unwrap(err))
+ errMsg = fmt.Sprintf("%v %v (cause %v)", getCode(err), err, errors.Unwrap(err))
} else {
errMsg = "<ok>"
}
@@ -31,7 +41,7 @@ func compareResults(t *testing.T, ret bool, err error, expected VerifyErrCode, c
case errAny:
wantErrCode = "<any>"
default:
- wantErrCode = strconv.Itoa(int(expected))
+ wantErrCode = strconv.Itoa(expected)
}
t.Errorf("%v\nerror = %v, wantErr %v", call(), errMsg, wantErrCode)
@@ -63,7 +73,7 @@ func Test_verifyWithKeys(t *testing.T) {
}
tests := []struct {
- result VerifyErrCode
+ result detailedVerifyErrorCode
testName string
signatureFile string
jsonFile string
@@ -71,46 +81,46 @@ func Test_verifyWithKeys(t *testing.T) {
minSignTime uint64
allowedPks []string
}{
- {ErrInvalidSignatureAlgorithm, "pure", "server_list.json.pure.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidSignatureAlgorithm, "pure", "server_list.json.pure.minisig", "server_list.json", "server_list.json", 10, pk},
{ok, "valid server_list", "server_list.json.minisig", "server_list.json", "server_list.json", 10, pk},
{ok, "TC no hashed", "server_list.json.tc_nohashed.minisig", "server_list.json", "server_list.json", 10, pk},
{ok, "TC later time", "server_list.json.tc_latertime.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrWrongFileName, "server_list TC file:organization_list", "server_list.json.tc_orglist.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrWrongFileName, "organization_list as server_list", "organization_list.json.minisig", "organization_list.json", "server_list.json", 10, pk},
- {ErrWrongFileName, "TC file:otherfile", "server_list.json.tc_otherfile.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrInvalidTrustedComment, "TC no file", "server_list.json.tc_nofile.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrInvalidTrustedComment, "TC no time", "server_list.json.tc_notime.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errWrongFileName, "server_list TC file:organization_list", "server_list.json.tc_orglist.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errWrongFileName, "organization_list as server_list", "organization_list.json.minisig", "organization_list.json", "server_list.json", 10, pk},
+ {errWrongFileName, "TC file:otherfile", "server_list.json.tc_otherfile.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidTrustedComment, "TC no file", "server_list.json.tc_nofile.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidTrustedComment, "TC no time", "server_list.json.tc_notime.minisig", "server_list.json", "server_list.json", 10, pk},
{errAny, "TC empty time", "server_list.json.tc_emptytime.minisig", "server_list.json", "server_list.json", 10, pk},
{errAny, "TC empty file", "server_list.json.tc_emptyfile.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrInvalidTrustedComment, "TC random", "server_list.json.tc_random.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidTrustedComment, "TC random", "server_list.json.tc_random.minisig", "server_list.json", "server_list.json", 10, pk},
{ok, "large time", "server_list.json.large_time.minisig", "server_list.json", "server_list.json", 43e8, pk},
{ok, "lower min time", "server_list.json.minisig", "server_list.json", "server_list.json", 5, pk},
- {ErrTooOld, "higher min time", "server_list.json.minisig", "server_list.json", "server_list.json", 11, pk},
+ {errTooOld, "higher min time", "server_list.json.minisig", "server_list.json", "server_list.json", 11, pk},
{ok, "valid organization_list", "organization_list.json.minisig", "organization_list.json", "organization_list.json", 10, pk},
- {ErrWrongFileName, "organization_list TC file:server_list", "organization_list.json.tc_servlist.minisig", "organization_list.json", "organization_list.json", 10, pk},
- {ErrWrongFileName, "server_list as organization_list", "server_list.json.minisig", "server_list.json", "organization_list.json", 10, pk},
+ {errWrongFileName, "organization_list TC file:server_list", "organization_list.json.tc_servlist.minisig", "organization_list.json", "organization_list.json", 10, pk},
+ {errWrongFileName, "server_list as organization_list", "server_list.json.minisig", "server_list.json", "organization_list.json", 10, pk},
- {ErrUnknownExpectedFileName, "valid other_list", "other_list.json.minisig", "other_list.json", "other_list.json", 10, pk},
- {ErrWrongFileName, "other_list as server_list", "other_list.json.minisig", "other_list.json", "server_list.json", 10, pk},
+ {errUnknownExpectedFileName, "valid other_list", "other_list.json.minisig", "other_list.json", "other_list.json", 10, pk},
+ {errWrongFileName, "other_list as server_list", "other_list.json.minisig", "other_list.json", "server_list.json", 10, pk},
- {ErrInvalidSignatureFormat, "invalid signature file", "random.txt", "server_list.json", "server_list.json", 10, pk},
- {ErrInvalidSignatureFormat, "empty signature file", "empty", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidSignatureFormat, "invalid signature file", "random.txt", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidSignatureFormat, "empty signature file", "empty", "server_list.json", "server_list.json", 10, pk},
- {ErrWrongKey, "wrong key", "server_list.json.wrong_key.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errWrongKey, "wrong key", "server_list.json.wrong_key.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrInvalidSignatureAlgorithm, "forged pure signature", "server_list.json.forged_pure.minisig", "server_list.json.blake2b", "server_list.json", 10, pk},
- {ErrInvalidSignature, "forged key ID", "server_list.json.forged_keyid.minisig", "server_list.json", "server_list.json", 10, pk},
+ {errInvalidSignatureAlgorithm, "forged pure signature", "server_list.json.forged_pure.minisig", "server_list.json.blake2b", "server_list.json", 10, pk},
+ {errInvalidSignature, "forged key ID", "server_list.json.forged_keyid.minisig", "server_list.json", "server_list.json", 10, pk},
- {ErrWrongKey, "no allowed keys", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{}},
+ {errWrongKey, "no allowed keys", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{}},
{ok, "multiple allowed keys 1", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{
pk[0], "RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ",
}},
{ok, "multiple allowed keys 2", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{
"RWSf0PYToIUJmDlsz21YOXvgQzHj9NSdyJUqEY5ZdfS9GepeXt3+JJRZ", pk[0],
}},
- {ErrInvalidPublicKey, "invalid allowed key", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{"AAA"}},
+ {errInvalidPublicKey, "invalid allowed key", "server_list.json.minisig", "server_list.json", "server_list.json", 10, []string{"AAA"}},
}
files := map[string][]byte{}
@@ -140,7 +150,7 @@ func Test_verifyWithKeys(t *testing.T) {
t.Parallel()
valid, err := verifyWithKeys(string(files[tt.signatureFile]), files[tt.jsonFile],
tt.expectedFileName, tt.minSignTime, tt.allowedPks)
- compareResults(t, valid, err, tt.result, func() string {
+ compareResults(t, valid, err, int(tt.result), func() string {
return fmt.Sprintf("verifyWithKeys(%q, %q, %q, %v, %v)",
tt.signatureFile, tt.jsonFile, tt.expectedFileName, tt.minSignTime, tt.allowedPks)
})
@@ -151,7 +161,7 @@ func Test_verifyWithKeys(t *testing.T) {
func Test_Verify(t *testing.T) {
var err error
tests := []struct {
- result VerifyErrCode
+ result VerifyErrorCode
testName string
signatureFile string
jsonFile string
@@ -159,9 +169,9 @@ func Test_Verify(t *testing.T) {
minSignTime uint64
}{
//TODO tests with real valid *prehashed* signatures
- {ErrInvalidSignatureAlgorithm, "pure server_list", "server_list-1.json.pure.minisig", "server_list-1.json", "server_list.json", 1636532223},
- {ErrInvalidSignatureAlgorithm, "pure organization_list", "organization_list-1.json.pure.minisig", "organization_list-1.json", "organization_list.json", 1636532223},
- {ErrWrongKey, "wrong key", "../dummy/server_list.json.minisig", "../dummy/server_list.json", "server_list.json", 10},
+ {ErrInvalidSignature, "pure server_list", "server_list-1.json.pure.minisig", "server_list-1.json", "server_list.json", 1636532223},
+ {ErrInvalidSignature, "pure organization_list", "organization_list-1.json.pure.minisig", "organization_list-1.json", "organization_list.json", 1636532223},
+ {ErrInvalidSignatureUnknownKey, "wrong key", "../dummy/server_list.json.minisig", "../dummy/server_list.json", "server_list.json", 10},
}
files := map[string][]byte{}
@@ -190,7 +200,7 @@ func Test_Verify(t *testing.T) {
t.Run(tt.testName, func(t *testing.T) {
t.Parallel()
valid, err := Verify(string(files[tt.signatureFile]), files[tt.jsonFile], tt.expectedFileName, tt.minSignTime)
- compareResults(t, valid, err, tt.result, func() string {
+ compareResults(t, valid, err, int(tt.result), func() string {
return fmt.Sprintf("Verify(%q, %q, %q, %v)",
tt.signatureFile, tt.jsonFile, tt.expectedFileName, tt.minSignTime)
})
diff --git a/wrappers/csharp/.gitignore b/wrappers/csharp/.gitignore
new file mode 100644
index 0000000..115bb34
--- /dev/null
+++ b/wrappers/csharp/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+/packages/
+.vs/
diff --git a/wrappers/csharp/Discovery.cs b/wrappers/csharp/Discovery.cs
new file mode 100644
index 0000000..0d6fef1
--- /dev/null
+++ b/wrappers/csharp/Discovery.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Text;
+
+[assembly: InternalsVisibleTo("EduVpnCommonTests")]
+
+namespace EduVpnCommon
+{
+ public static class Discovery
+ {
+ /// <summary>
+ /// Verifies the signature on the JSON server_list.json/organization_list.json file.
+ /// If the function returns the signature is valid for the given file type.
+ /// </summary>
+ /// <param name="signatureFileContent">.minisig signature file contents.</param>
+ /// <param name="signedJson">Signed .json file contents.</param>
+ /// <param name="expectedFileName">The file type to be verified, one of <c>server_list.json</c> or <c>organization_list.json</c>.</param>
+ /// <param name="minSignTime">Minimum time for signature. Should be set to at least the time in a previously retrieved file.</param>
+ /// <exception cref="ArgumentException">If <c>expectedFileName</c> is not one of the allowed valued.</exception>
+ /// <exception cref="VerifyException">If signature verification fails.</exception>
+ public static void Verify(
+ ArraySegment<byte> signatureFileContent,
+ ArraySegment<byte> signedJson,
+ string expectedFileName,
+ DateTimeOffset minSignTime)
+ {
+ VerifyReturnCode result;
+ {
+ using var signatureHandle = GoSliceHandle.FromArray(signatureFileContent);
+ using var jsonHandle = GoSliceHandle.FromArray(signedJson);
+ using var expectedFileHandle = GoSliceHandle.FromString(expectedFileName);
+
+ result = Verify(signatureHandle.Slice, jsonHandle.Slice, expectedFileHandle.Slice,
+ (ulong) minSignTime.ToUnixTimeSeconds());
+ }
+
+ if (result != VerifyReturnCode.Ok)
+ {
+ if (result == VerifyReturnCode.ErrUnknownExpectedFileName)
+ throw new ArgumentException("unknown name", nameof(expectedFileName));
+ throw new VerifyException((VerifyErrorCode) result);
+ }
+ }
+
+ /// <summary>Use for testing only, see Go documentation.</summary>
+ internal static void InsecureTestingSetExtraKey(string keyString)
+ {
+ using var keyHandle = GoSliceHandle.FromString(keyString);
+ InsecureTestingSetExtraKey(keyHandle.Slice);
+ }
+
+ const string VerifyLibName = "eduvpn_verify";
+
+ [DllImport(VerifyLibName)]
+ static extern VerifyReturnCode Verify(GoSlice signatureFileContent, GoSlice signedJson, GoSlice expectedFileName, ulong minSignTime);
+
+ [DllImport(VerifyLibName)] static extern void InsecureTestingSetExtraKey(GoSlice keyStr);
+
+ class GoSliceHandle : IDisposable
+ {
+ GCHandle gcHandle_;
+ readonly GoSlice slice_;
+
+ public GoSlice Slice => gcHandle_.IsAllocated
+ ? slice_
+ : throw new InvalidOperationException("Handle was disposed");
+
+ GoSliceHandle(Array array, int offset, int count)
+ {
+ gcHandle_ = GCHandle.Alloc(array, GCHandleType.Pinned);
+ var elemSize = Marshal.SizeOf(array.GetType().GetElementType()!);
+ slice_ = new GoSlice(gcHandle_.AddrOfPinnedObject() + offset * elemSize, count * elemSize);
+ }
+
+ public static GoSliceHandle FromArray<T>(ArraySegment<T> segment) where T : struct =>
+ new GoSliceHandle(segment.Array!, segment.Offset, segment.Count);
+
+ public static GoSliceHandle FromString(string str) =>
+ FromArray(new ArraySegment<byte>(Encoding.UTF8.GetBytes(str)));
+
+ public void Dispose() => gcHandle_.Free();
+ }
+
+ readonly struct GoSlice
+ {
+ readonly IntPtr data_;
+ readonly long len_, cap_;
+
+ public GoSlice(IntPtr data, long len, long cap)
+ {
+ data_ = data;
+ len_ = len;
+ cap_ = cap;
+ }
+
+ public GoSlice(IntPtr data, long len) : this(data, len, len) { }
+ }
+ }
+
+ public class VerifyException : Exception
+ {
+ public VerifyErrorCode Code { get; }
+
+ internal VerifyException(VerifyErrorCode code) : base(GetMessage(code)) => Code = code;
+
+ static string GetMessage(VerifyErrorCode code) => code switch
+ {
+ VerifyErrorCode.ErrInvalidSignature => "invalid signature",
+ VerifyErrorCode.ErrInvalidSignatureUnknownKey => "invalid signature (unknown key)",
+ VerifyErrorCode.ErrTooOld => "replay of previous signature (rollback)",
+ _ => $"unknown verify error ({code})"
+ };
+ }
+
+ public enum VerifyErrorCode
+ {
+ /// <summary>Signature is invalid (for the expected file type).</summary>
+ ErrInvalidSignature = VerifyReturnCode.ErrUnknownExpectedFileName + 1,
+
+ /// <summary>Signature was created with an unknown key and has not been verified.</summary>
+ ErrInvalidSignatureUnknownKey,
+
+ /// <summary>Signature has a timestamp lower than the specified minimum signing time.</summary>
+ ErrTooOld
+ }
+
+ enum VerifyReturnCode
+ {
+ Ok,
+ ErrUnknownExpectedFileName
+
+ //...
+ }
+} \ No newline at end of file
diff --git a/wrappers/csharp/EduVpnCommon.csproj b/wrappers/csharp/EduVpnCommon.csproj
new file mode 100644
index 0000000..1bbbc23
--- /dev/null
+++ b/wrappers/csharp/EduVpnCommon.csproj
@@ -0,0 +1,27 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netstandard2.0</TargetFramework>
+ <LangVersion>8</LangVersion>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Remove="EduVpnCommonTests/**" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <EmbeddedResource Remove="EduVpnCommonTests/**" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Remove="EduVpnCommonTests/**" />
+ <None Condition="Exists('../../exports/eduvpn_verify.dll')" Include="../../exports/eduvpn_verify.dll">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Condition="Exists('../../exports/eduvpn_verify.so')" Include="../../exports/eduvpn_verify.so">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/wrappers/csharp/EduVpnCommon.sln b/wrappers/csharp/EduVpnCommon.sln
new file mode 100644
index 0000000..0d59e2f
--- /dev/null
+++ b/wrappers/csharp/EduVpnCommon.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EduVpnCommon", "EduVpnCommon.csproj", "{D95F43A0-EF74-41FD-A526-8987151ABB30}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EduVpnCommonTests", "EduVpnCommonTests/EduVpnCommonTests.csproj", "{B7A75F1A-C83F-4FAB-AD16-E9DB7EF58EBC}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {D95F43A0-EF74-41FD-A526-8987151ABB30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D95F43A0-EF74-41FD-A526-8987151ABB30}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D95F43A0-EF74-41FD-A526-8987151ABB30}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D95F43A0-EF74-41FD-A526-8987151ABB30}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7A75F1A-C83F-4FAB-AD16-E9DB7EF58EBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7A75F1A-C83F-4FAB-AD16-E9DB7EF58EBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7A75F1A-C83F-4FAB-AD16-E9DB7EF58EBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7A75F1A-C83F-4FAB-AD16-E9DB7EF58EBC}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/wrappers/csharp/EduVpnCommonTests/EduVpnCommonTests.csproj b/wrappers/csharp/EduVpnCommonTests/EduVpnCommonTests.csproj
new file mode 100644
index 0000000..cf58249
--- /dev/null
+++ b/wrappers/csharp/EduVpnCommonTests/EduVpnCommonTests.csproj
@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
+ <PackageReference Include="NUnit" Version="3.13.1" />
+ <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
+ <PackageReference Include="coverlet.collector" Version="3.0.2" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="../EduVpnCommon.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/wrappers/csharp/EduVpnCommonTests/VerifyTests.cs b/wrappers/csharp/EduVpnCommonTests/VerifyTests.cs
new file mode 100644
index 0000000..933f2bc
--- /dev/null
+++ b/wrappers/csharp/EduVpnCommonTests/VerifyTests.cs
@@ -0,0 +1,72 @@
+using System;
+using System.IO;
+using System.Linq;
+using EduVpnCommon;
+using NUnit.Framework;
+
+namespace EduVpnCommonTests
+{
+ [TestFixture(TestOf = typeof(Discovery)), Parallelizable]
+ public class VerifyTests
+ {
+ // Relative to e.g. EduVpnCommonTests/bin/Debug/net5.0
+ readonly string testDataDir_ = $"{TestContext.CurrentContext.TestDirectory}/../../../../../../test_data";
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp() =>
+ Discovery.InsecureTestingSetExtraKey(File.ReadLines($"{testDataDir_}/dummy/public.key").Last());
+
+ [Test]
+ [TestCase("dummy/server_list.json.minisig", "dummy/server_list.json", "server_list.json")]
+ [TestCase("dummy/organization_list.json.minisig", "dummy/organization_list.json", "organization_list.json")]
+ public void TestValid(string sigFile, string jsonFile, string expectedFileName) =>
+ Discovery.Verify(
+ File.ReadAllBytes($"{testDataDir_}/{sigFile}"),
+ File.ReadAllBytes($"{testDataDir_}/{jsonFile}"),
+ expectedFileName,
+ DateTimeOffset.UnixEpoch);
+
+ [Test]
+ [TestCase("dummy/random.txt", "dummy/server_list.json", "server_list.json")]
+ public void TestInvalidSignature(string sigFile, string jsonFile, string expectedFileName) =>
+ Assert.Throws(Is.TypeOf<VerifyException>()
+ .And.Property(nameof(VerifyException.Code)).EqualTo(VerifyErrorCode.ErrInvalidSignature),
+ () => Discovery.Verify(
+ File.ReadAllBytes($"{testDataDir_}/{sigFile}"),
+ File.ReadAllBytes($"{testDataDir_}/{jsonFile}"),
+ expectedFileName,
+ DateTimeOffset.UnixEpoch));
+
+ [Test]
+ [TestCase("dummy/server_list.json.wrong_key.minisig", "dummy/server_list.json", "server_list.json")]
+ public void TestWrongKey(string sigFile, string jsonFile, string expectedFileName) =>
+ Assert.Throws(Is.TypeOf<VerifyException>()
+ .And.Property(nameof(VerifyException.Code)).EqualTo(VerifyErrorCode.ErrInvalidSignatureUnknownKey),
+ () => Discovery.Verify(
+ File.ReadAllBytes($"{testDataDir_}/{sigFile}"),
+ File.ReadAllBytes($"{testDataDir_}/{jsonFile}"),
+ expectedFileName,
+ DateTimeOffset.UnixEpoch));
+
+ [Test]
+ [TestCase("dummy/server_list.json.minisig", "dummy/server_list.json", "server_list.json")]
+ public void TestOldSignature(string sigFile, string jsonFile, string expectedFileName) =>
+ Assert.Throws(Is.TypeOf<VerifyException>()
+ .And.Property(nameof(VerifyException.Code)).EqualTo(VerifyErrorCode.ErrTooOld),
+ () => Discovery.Verify(
+ File.ReadAllBytes($"{testDataDir_}/{sigFile}"),
+ File.ReadAllBytes($"{testDataDir_}/{jsonFile}"),
+ expectedFileName,
+ DateTimeOffset.MaxValue));
+
+ [Test]
+ [TestCase("dummy/other_list.json.minisig", "dummy/other_list.json", "other_list.json")]
+ public void TestUnknownExpectedFile(string sigFile, string jsonFile, string expectedFileName) =>
+ Assert.Throws<ArgumentException>(
+ () => Discovery.Verify(
+ File.ReadAllBytes($"{testDataDir_}/{sigFile}"),
+ File.ReadAllBytes($"{testDataDir_}/{jsonFile}"),
+ expectedFileName,
+ DateTimeOffset.UnixEpoch));
+ }
+} \ No newline at end of file
diff --git a/wrappers/csharp/Makefile b/wrappers/csharp/Makefile
new file mode 100644
index 0000000..293a7a2
--- /dev/null
+++ b/wrappers/csharp/Makefile
@@ -0,0 +1,4 @@
+.PHONY: test
+
+test:
+ dotnet test