-
Notifications
You must be signed in to change notification settings - Fork 54
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
Changes from all commits
4008c6d
52d27f6
be15ba0
de81e82
0641b3b
4d8a943
7986ae1
d26222c
12f0960
4d915af
6da37b1
d2a25aa
d09d091
a8d37cf
52d6036
5af3844
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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! | ||
|
||
|
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{}) | ||
|
||
// 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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it worth checking the length of arguments? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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/ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @andorsk wouldn't the main function trap this and restart? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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{} { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
|
||
pubKey, _, _ := crypto.GenerateKeyByKeyType(crypto.Ed25519) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we pull out the errs here and below? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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{} { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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: // 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
}
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
||
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) | ||
} |
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> |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
✅