Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Wasm bindings - a start #265

Merged
merged 16 commits into from
Jan 11, 2023
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19.2
go-version: 1.19.4

- name: Install Mage
run: go install github.com/magefile/mage
Expand All @@ -38,7 +38,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19.2
go-version: 1.19.4

- name: Install Mage
run: go install github.com/magefile/mage
Expand Down
18 changes: 16 additions & 2 deletions magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,22 @@ const (

// Build builds the library.
func Build() error {
println("Building...")
return sh.Run(Go, "build", "-tags", "jwx_es256k", "./...")

fmt.Println("Building...")
if err := sh.Run(Go, "build", "-tags", "jwx_es256k", "./..."); err != nil {
return err
}
return BuildWasm()
}

func BuildWasm() error {

fmt.Println("Building wasm...")
env := map[string]string{
"GOOS": "js",
"GOARCH": "wasm",
}
return sh.RunWith(env, Go, "build", "-tags", "jwx_es256k", "-o", "./wasm/static/main.wasm", "./wasm")
}

// Clean deletes any build artifacts.
Expand Down
19 changes: 19 additions & 0 deletions wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# WASM bindings of ssi-sdk into javascript

https://github.com/TBD54566975/ssi-sdk is the home of self soverign stuff at TBD, implented in golang.
We want to use this from the web as well, this minimal demo shows how.

`webserver` is a sample webserver which serves up a sample js app from `static` which also contains the wasm bindings and wasm "binary".

# status
This just has some example usage of apis to start with.

# building (from top level)

`mage buildwasm`

# running web server to test out

`cd wasm && go run webserver/main.go` - then go to localhost:3000 to try it out!


103 changes: 103 additions & 0 deletions wasm/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//go:build js && wasm

package main

import (
"crypto/ed25519"
"encoding/base64"
"log"
"syscall/js"
"time"

"github.com/goccy/go-json"

"github.com/TBD54566975/ssi-sdk/crypto"
"github.com/TBD54566975/ssi-sdk/did"
)

/*
* This is the glue to bind the functions into javascript so they can be called
*/
func main() {
done := make(chan struct{})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious what the difference is if this channel is removed?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, you need to keep the process alive.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Bind the functions to javascript
js.Global().Set("sayHello", js.FuncOf(sayHello))
js.Global().Set("generateKey", js.FuncOf(generateKey))
js.Global().Set("makeDid", js.FuncOf(makeDid))
js.Global().Set("resolveDid", js.FuncOf(resolveDid))

for {
func() {
defer func() {
if r := recover(); r != nil {
log.Println("Recovered from panic:", r)
time.Sleep(time.Second)
}
}()

<-done
}()
}
}

// 1. Simplest function - note we wrap things with js.ValueOf (if a primitive you don't technically need to)
func sayHello(_ js.Value, args []js.Value) interface{} {
return js.ValueOf("Hello from golang via wasm!")
}

// 2. Calling a ssi-sdk function directly - but returning a plain old string
// TODO: check arg lentgh and return an error if not correct
func generateKey(_ js.Value, args []js.Value) interface{} {

keyType := args[0].String()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth checking the length of arguments?

Copy link
Member

@mistermoe mistermoe Dec 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for sure @andresuribe87 this method will currently blow up if no args are provided. the most i'd do in this PR though is add a TODO above this line saying that arg length needs to be checked and handled appropriate (aka throw error if no args provided). Then, a general pattern can be figured out and applied for error handling in another PR

Copy link
Contributor

@andorsk andorsk Jan 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed here. Here was how I did it, but I definitely would want to revisit it. I didn't feel it was robust enough:

// checks the args length with the input
// TODO: more robust argument checking. i.e Maybe align with a validator?
func checkArgs(actual []js.Value, args ...string) error {
	if len(actual) < len(args) {
		return errors.New(fmt.Sprintf("not enough arguments. Need %v", args)) // nit: change to errorf
	}
	return nil
}

then call it later:

err := checkArgs(args, "id")
if err != nil {
   return err.Error()
}

re: blow up: if this blows up, it doesn't recover. Which is a big issue.

So I suggest we have a recover mechanic in place before getting too deep into actually building out the methods.

Check out https://go.dev/blog/defer-panic-and-recover, specifically the recover mechanic native to golang and https://www.geeksforgeeks.org/recover-in-golang/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andorsk wouldn't the main function trap this and restart?

Copy link
Contributor

@andorsk andorsk Jan 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if panic, it would exit main function and it wouldn't restart unless you handled recover. At least, my experience.

kt := crypto.KeyType(keyType)
if !crypto.IsSupportedKeyType(kt) {
return js.ValueOf("Unknown key type")
}
publicKey, _, _ := crypto.GenerateKeyByKeyType(kt)
pubKeyBytes, _ := crypto.PubKeyToBytes(publicKey)
return js.ValueOf(base64.StdEncoding.EncodeToString(pubKeyBytes))
}

// 3. Returning a richer object, converting to json and then unmarshalling to make it a js object
func makeDid(_ js.Value, args []js.Value) interface{} {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: makeDID can also replace interface{} with the new any alias


pubKey, _, _ := crypto.GenerateKeyByKeyType(crypto.Ed25519)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we pull out the errs here and below?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing to think about with WASM is how it interfaces with storage. It's not quite clear cut. I.e you generate the public key in WASM, but you'll now how to figure out a way to transfer and serialize the key to the application handling the storage. That transfer always felt a little "iffy" to me. I.e you've now sent your private key to your application, and that may be a security risk.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andorsk I am not that worried about that at the moment: I like the idea of using WASM for pure computation (ie functions without side effects and no state outside its bounds) were possible especially with ssi-ssd (people can correct me if I am wrong). I know you could build your whole app and state in wasm, but I don't think that is needed here is it?

Copy link
Contributor

@andorsk andorsk Jan 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelneale fair points. I get using WASM for pure computation, and think a stateless app is probably fair. One question though here: moving keys back and forth between local storage to the WASM application. I don't have an answer here, but does shuffling the key around between WASM and JS runtime render any security issues?

i.e

var pk = JSON.parse(CreatePK()) // generates PK. Now key is JS Object. You could store this somewhere after.
doX(JSON.stringify(pk)) // PK is serialized and sent. 

Totally possible I'm off here.

didKey, _ := did.CreateDIDKey(crypto.Ed25519, pubKey.(ed25519.PublicKey))
result, _ := didKey.Expand()

// unmarshall into json bytes, then back into a simple struct for converting to js
resultBytes, _ := json.Marshal(result)
var resultObj map[string]interface{}
json.Unmarshal(resultBytes, &resultObj)
return js.ValueOf(resultObj)

}

func resolveDid(_ js.Value, args []js.Value) interface{} {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a way to distinguish in the JS if this was an error vs the return object?

Copy link
Contributor

@andorsk andorsk Jan 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a great question. I've had a similar question and I've not been able to determine a way aside from serializing a string with the word "error". Maybe the right way is to build a wrapper around the objects such as the following:

STRAWMAN:

type ObjectType string

cont (
    TypeError ObjectType = "error"
     TypeObject ObjectType = "object"
) 
type ObjectWrapper  {
     ObjectType ObjectType `json:"type"`
     Object interface{} `json:"object"`
}

or something. There's no "standard" way though, AFAIK. The above feels like a hack, but might be the best way to move forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@andorsk @nitro-neal I am thinking of a convention based json object being returned with an option error top level key

Copy link
Member

@mistermoe mistermoe Jan 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nitro-neal @andorsk @michaelneale yeah there's no easy way to throw a JS exception from web assembly (or at least none that i could think of) to support something like

try {
  resolveDid("did:janky:alice")
} catch (e) {
  console.log(error);
}

WASM execution happens within an isolated sandbox in a separate execution environment that cannot directly access the JS stack. So anything thrown from WASM gets caught by the WASM runtime and handled as if the WASM code panicked.

@michaelneale your top level error property could definitely work. Another option that may feel a bit more like catching an exception in JS land would be to return a Promise from WASM that is resolved or rejected. Using await semantics you'd be able to try/catch the returned promise, e.g.

try {
  await resolveDid("did:janky:alice")
} catch (e) {
  console.log(e);
}

the underlying Go code would look like this:

func resolveDid(_ js.Value, args []js.Value) interface{} {
    handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        resolve := args[0]
        reject := args[1]
    
        go func() {
            didString := args[0].String()
            resolvers := []did.Resolution{did.KeyResolver{}, did.WebResolver{}, did.PKHResolver{}, did.PeerResolver{}}
            resolver, err := did.NewResolver(resolvers...)
            
            if err != nil {
                // err should be an instance of `error`, eg `errors.New("some error")`
                errorConstructor := js.Global().Get("Error")
                errorObject := errorConstructor.New(err.Error())
                reject.Invoke(errorObject)
            }
    
            doc, err := resolver.Resolve(didString)
            if err != nil {
                errorConstructor := js.Global().Get("Error")
                errorObject := errorConstructor.New(err.Error())
                reject.Invoke(errorObject)
            }
    
            resultBytes, err := json.Marshal(doc)
            if err != nil {
                errorConstructor := js.Global().Get("Error")
                errorObject := errorConstructor.New(err.Error())
                reject.Invoke(errorObject)
            }
    
            var resultObj map[string]interface{}
            err = json.Unmarshal(resultBytes, &resultObj)
            if err != nil {
                errorConstructor := js.Global().Get("Error")
                errorObject := errorConstructor.New(err.Error())
                reject.Invoke(errorObject)
            }
    
            resolve.Invoke(js.ValueOf(resultObj))
        }()
    
        return nil
    })
    
    promiseConstructor := js.Global().Get("Promise")
    return promiseConstructor.New(handler)
}

the go is def a bit uglier but i bet we can teach ChatGPT to return functions with this kind of wrapper.

random unnecessary info:
the "uneasy" way to throw exceptions from WASM into the JS runtime i think would require us to provide the assembler with a hint. so something like:

// throw stub in our go code
func Throw(exception string, message string)
;; assembler hint in ssi-sdk_js.s

TEXT ·Throw(SB), NOSPLIT, $0
  CallImport
  RET
// manually added to wasm_exec.js into `importObject.go` object
// this object already exists in `wasm_exec.js`, just adding here for sake of example
this.importObject = {
// `go` already exists in `wasm_exec.js`. just adding here for sake of example
  go: {
    // this is what we would need to add ourselves
    // func Throw(exception string, message string)
    'ssi-sdk.Throw': (sp) => {
      const exception = loadString(sp + 8)
      const message = loadString(sp + 24)
      const throwable = globalThis[exception](message)
      throw throwable
    }
  }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heavy +1 to returning a promise. DID resolution will typically involve making a network request, which reinforces the fact that it should be an awaitable.

In fact, I can see the Resolve method evolving to become more go idiomatic and accepting a context.Context object as the first parameter. This object would carry cancellation signals.


didString := args[0].String()
resolvers := []did.Resolution{did.KeyResolver{}, did.WebResolver{}, did.PKHResolver{}, did.PeerResolver{}}
resolver, err := did.NewResolver(resolvers...)
if err != nil {
return err
}

doc, err := resolver.Resolve(didString)
if err != nil {
return err
}

resultBytes, err := json.Marshal(doc)
if err != nil {
return err
}
var resultObj map[string]interface{}
err = json.Unmarshal(resultBytes, &resultObj)
if err != nil {
return err
}

return js.ValueOf(resultObj)
}
31 changes: 31 additions & 0 deletions wasm/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<html>

<head>
<meta charset="utf-8" />
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
</head>

<body>
<div>
<h1>WASM SSI-SDK Example</h1>
<button id="generateKey">generateKey</button>
</div>
<script>

const generateKeyBtn = document.querySelector("#generateKey");
generateKeyBtn.addEventListener("click", e => {
alert(sayHello());
alert(generateKey("Ed25519"));
console.log(resolveDid("did:peer:0z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH"));
console.log(makeDid());
});
</script>
</body>

</html>
Binary file added wasm/static/main.wasm
Binary file not shown.
Loading