--- title: WASM Module Signing description: Ed25519 code signing and SHA-256 hash pinning for WebAssembly module bytes — supply-chain verification, not a JavaScript binding layer. sidebar: order: 6 icon: package-check --- ## This is not a js/wasm binding layer The package name misleads. `github.com/sonr-io/crypto/wasm` contains no `//go:build js,wasm` constraint, does not import `syscall/js`, and exposes nothing that runs inside a browser. Verified by reading both source files (`signer.go`, `verifier.go`): the only imports are `crypto/ed25519`, `crypto/rand`, `crypto/sha256`, `encoding/base64`, `encoding/hex`, `encoding/json`, `fmt`, `sync`, and `time`. What it actually is: **Ed25519 code signing and SHA-256 hash pinning over WebAssembly module bytes.** It answers one question — *is this `.wasm` blob the one I approved?* — before a host embeds and executes it. That is supply-chain verification, and it is plain Go that compiles and runs on any target. **Reach for this when** your program loads WASM plugins or modules from disk, a registry, or the network and must refuse anything it does not recognise. **Do not reach for this when** you need sandboxing or capability control over what a module can *do* once loaded — that is the runtime's job, not this package's. Verification tells you *which* code you are about to run, never what it will do. ## Trust model A `SignatureVerifier` starts empty and rejects everything with `"no trusted keys configured"`. Verification is only as strong as the key set you install with `AddTrustedKey` / `AddTrustedKeyFromHex`. Those public keys must reach the verifier through a channel you already trust — baked into the binary, delivered by your config management, pinned in your deployment manifest. A key learned from the same place as the module buys you nothing. The publisher holds an Ed25519 private key and calls `SignModule` or `CreateSignatureManifest` over the exact bytes that will be shipped. The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the Ed25519 signature against a trusted key. `HashVerifier` is deliberately separate from signing. A pinned hash constrains you to one exact build even if a signing key is later compromised — it is a second, non-overlapping control, not a weaker substitute for a signature. ## Signing ```go func NewSigner() (*Signer, error) func NewSignerFromPrivateKey(privateKey ed25519.PrivateKey) (*Signer, error) func (s *Signer) Sign(wasmBytes []byte) ([]byte, error) func (s *Signer) GetPublicKey() []byte func (s *Signer) GetPublicKeyHex() string func (s *Signer) ExportPrivateKey() []byte ``` `NewSigner` generates a fresh Ed25519 keypair from `crypto/rand`. `NewSignerFromPrivateKey` requires exactly `ed25519.PrivateKeySize` (64) bytes and derives the public key from it, erroring with `"invalid private key size: expected 64, got N"` otherwise. `Sign` produces a 64-byte `ed25519.Sign(priv, wasmBytes)` over the **raw module bytes** — not over the hash, and with no domain separation prefix. :::warning[`ExportPrivateKey` hands out the raw signing key] It returns `s.privateKey` directly — the live 64-byte `ed25519.PrivateKey` slice, not a copy. The caller can read it, and can also **mutate the signer's key in place** through the returned slice. Anything that receives this value can forge signatures for every module your key covers. Do not log it, serialize it, or pass it across a trust boundary; if you must persist a signing key, encrypt it with [AEAD](/symmetric/aead) and keep the plaintext lifetime as short as possible. ::: ## Signed modules ```go type SignedModule struct { Module []byte `json:"-"` // WASM bytecode, EXCLUDED from JSON Hash string `json:"hash"` // hex SHA-256 of Module Signature []byte `json:"signature"` // Ed25519, 64 bytes SignerID string `json:"signer_id"` Timestamp time.Time `json:"timestamp"` Version string `json:"version"` } func SignModule(signer *Signer, module []byte, signerID, version string) (*SignedModule, error) func VerifySignedModule(verifier *SignatureVerifier, module *SignedModule) error ``` `VerifySignedModule` runs two checks in order: 1. Recompute the SHA-256 hash over `module.Module` and compare with `module.Hash`; mismatch yields `"hash mismatch: expected …, got …"`. 2. If `SignerID` is non-empty, `verifier.VerifyWithKey(SignerID, Module, Signature)`; otherwise `verifier.Verify(Module, Signature)`, which tries every trusted key in turn. Grounded in `TestSignedModule` (`wasm/signer_test.go`): ```go wasm_sign_verify.go package main import ( "fmt" "github.com/sonr-io/crypto/wasm" ) func main() { // Publisher side. signer, err := wasm.NewSigner() if err != nil { panic(err) } module := []byte("test wasm module") // in practice, the .wasm file contents signed, err := wasm.SignModule(signer, module, "test-signer", "v1.0.0") if err != nil { panic(err) } fmt.Println("hash:", signed.Hash) publicKeyHex := signer.GetPublicKeyHex() // ship this out of band // Host side: trust is provisioned from the out-of-band key, not from `signed`. verifier := wasm.NewSignatureVerifier() if err := verifier.AddTrustedKeyFromHex("test-signer", publicKeyHex); err != nil { panic(err) } fmt.Println("trusted:", verifier.GetTrustedKeyIDs()) fmt.Println("ok:", wasm.VerifySignedModule(verifier, signed)) // nil // Tampering is caught at the hash check. signed.Module = []byte("tampered") fmt.Println("tampered:", wasm.VerifySignedModule(verifier, signed)) // "hash mismatch" } ``` :::note[`Module` is not serialized] `SignedModule.Module` carries `json:"-"`, so marshalling a `SignedModule` drops the bytecode. The JSON is metadata only; ship the `.wasm` file alongside it and reattach it to `Module` before calling `VerifySignedModule`, or the hash check compares against an empty module. ::: ## The `SignatureVerifier` ```go func NewSignatureVerifier() *SignatureVerifier func (v *SignatureVerifier) AddTrustedKey(keyID string, publicKey ed25519.PublicKey) error func (v *SignatureVerifier) AddTrustedKeyFromHex(keyID, publicKeyHex string) error func (v *SignatureVerifier) RemoveTrustedKey(keyID string) func (v *SignatureVerifier) GetTrustedKeyIDs() []string func (v *SignatureVerifier) Verify(wasmBytes, signature []byte) error func (v *SignatureVerifier) VerifyWithKey(keyID string, wasmBytes, signature []byte) error ``` `AddTrustedKey` requires exactly `ed25519.PublicKeySize` (32) bytes. The map is guarded by a `sync.RWMutex`, so a verifier is safe for concurrent use. Prefer `VerifyWithKey` over `Verify`. `Verify` iterates the whole trusted set and succeeds if *any* key validates, so it tells you the module is signed by someone you trust but not by **whom** — and it does not report which key matched. `VerifyWithKey` binds the check to an expected signer. ## Manifests A manifest decouples signature metadata from the module file, and supports multiple signatures. ```go type SignatureManifest struct { ModuleHash string `json:"module_hash"` Signatures []SignatureEntry `json:"signatures"` TrustedKeys []TrustedKeyEntry `json:"trusted_keys"` CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } type SignatureEntry struct { Signature string `json:"signature"` // base64 std encoding SignerID string `json:"signer_id"` Timestamp time.Time `json:"timestamp"` Algorithm string `json:"algorithm"` // always "Ed25519" } type TrustedKeyEntry struct { KeyID string `json:"key_id"` PublicKey string `json:"public_key"` // base64 std encoding AddedAt time.Time `json:"added_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` Purpose string `json:"purpose"` // e.g. "code-signing" } func CreateSignatureManifest(module []byte, signer *Signer, signerID string) (*SignatureManifest, error) func ExportManifest(manifest *SignatureManifest) ([]byte, error) func ImportManifest(data []byte) (*SignatureManifest, error) func VerifyWithManifest(module []byte, manifest *SignatureManifest) error ``` `CreateSignatureManifest` emits a manifest with exactly one `SignatureEntry` and one `TrustedKeyEntry` (`Purpose: "code-signing"`). `VerifyWithManifest` checks the module hash, then `ExpiresAt` on the manifest, then builds a fresh verifier from `manifest.TrustedKeys`, skipping any entry whose own `ExpiresAt` has passed. :::danger[`VerifyWithManifest` trusts the keys inside the manifest] It constructs its verifier from `manifest.TrustedKeys` — keys carried by the very document whose authenticity is in question. An attacker who can replace both the module and its manifest simply signs the replacement with their own key, lists that key in `TrustedKeys`, and `VerifyWithManifest` returns `nil`. `VerifyWithManifest` therefore establishes only **internal consistency**: this manifest describes this module. It establishes **no trust**. To get a real decision, verify against a key set you provisioned yourself: ```go manifest, err := wasm.ImportManifest(manifestJSON) if err != nil { return err } // Independent trust anchor — not manifest.TrustedKeys. verifier := wasm.NewSignatureVerifier() if err := verifier.AddTrustedKeyFromHex("release-key", pinnedPublicKeyHex); err != nil { return err } // Confirm the manifest describes this module and has not expired. if err := wasm.VerifyWithManifest(module, manifest); err != nil { return err } // Then check at least one signature against YOUR key. verified := false for _, entry := range manifest.Signatures { sig, err := base64.StdEncoding.DecodeString(entry.Signature) if err != nil { continue } if verifier.VerifyWithKey("release-key", module, sig) == nil { verified = true break } } if !verified { return errors.New("no signature from a pinned key") } ``` ::: :::warning[Expiry is optional and unauthenticated] `ExpiresAt` is a `*time.Time`; a `nil` value means "never expires" and `VerifyWithManifest` accepts it. Since the manifest is unsigned as a whole, an attacker rewriting the manifest can also clear or extend `ExpiresAt`. Only the individual `Signature` values are cryptographically protected, and each covers the module bytes alone — not `ModuleHash`, not `SignerID`, not `Timestamp`, and not any expiry field. ::: ## Hash pinning ```go func NewHashVerifier() *HashVerifier func (v *HashVerifier) ComputeHash(wasmBytes []byte) string // hex SHA-256 func (v *HashVerifier) AddTrustedHash(name, hash string) func (v *HashVerifier) GetTrustedHash(name string) (string, bool) func (v *HashVerifier) VerifyHash(name string, wasmBytes []byte) error func (v *HashVerifier) VerifyHashWithFallback(name string, wasmBytes []byte, fallbackHashes []string) error func (v *HashVerifier) ClearTrustedHashes() ``` `VerifyHash` errors with `"no trusted hash found for WASM module: "` when the name is unknown — so an unregistered module is denied by default, which is the right behaviour. The map is `sync.RWMutex`-guarded. :::danger[`VerifyHashWithFallback` mutates your pin set] On a fallback match it calls `AddTrustedHash(name, computedHash)`, **overwriting the pinned hash for that name**: ```go for _, fallbackHash := range fallbackHashes { if computedHash == fallbackHash { v.AddTrustedHash(name, computedHash) // pin replaced return nil } } ``` Every subsequent `VerifyHash(name, …)` now accepts the fallback build and rejects the original. Two consequences: - Pinning becomes trust-on-first-use with silent promotion. If the fallback list is ever wider than you intended — read from config, a response body, a rollback table — the pin follows it. - The change is invisible: nothing is returned or logged to say the pin moved. If you need to accept several builds, keep them in your own set and call `VerifyHash` (or compare `ComputeHash` output) against each, so the pin set stays under your control. ::: ## Hash chains ```go type HashEntry struct { Version string `json:"version"` Hash string `json:"hash"` PreviousHash string `json:"previous_hash"` Timestamp int64 `json:"timestamp"` } func NewHashChain() *HashChain func (hc *HashChain) AddEntry(version, hash string, timestamp int64) error func (hc *HashChain) GetLatestEntry() (*HashEntry, error) func (hc *HashChain) VerifyChain() error ``` `AddEntry` appends an entry whose `PreviousHash` is copied from the previous entry's `Hash` (empty for the first). `VerifyChain` accepts an empty chain, requires the first entry's `PreviousHash` to be empty, and then checks that each `PreviousHash` equals the preceding `Hash`. `GetLatestEntry` returns a **copy** of the last entry, or `"hash chain is empty"`. :::warning[The chain is a linkage check, not a cryptographic commitment] `AddEntry` always sets `PreviousHash` correctly, so `VerifyChain` **cannot fail** for a chain built through `AddEntry`. It only becomes meaningful for a chain deserialized from an untrusted source — which is exactly how `TestHashChain_BrokenChain` exercises it, by assigning the internal slice directly. Even then, `PreviousHash` is a plain string field, not a hash *over* the previous entry. Nothing binds `Version` or `Timestamp` to anything, and no entry is signed. An attacker who can rewrite the chain can produce a self-consistent chain of their own choosing. Treat it as an audit-trail convenience for update ordering, and get your integrity from `SignatureVerifier` and `HashVerifier`. ::: ## `SecurityPolicy` :::danger[`Validate` only checks the size] `SecurityPolicy.Validate(wasmBytes []byte) error` is, in full: ```go func (p *SecurityPolicy) Validate(wasmBytes []byte) error { if p.MaxModuleSize > 0 && int64(len(wasmBytes)) > p.MaxModuleSize { return fmt.Errorf("WASM module size %d exceeds maximum allowed size %d", len(wasmBytes), p.MaxModuleSize) } return nil } ``` `RequireHashVerification`, `RequireSignature` and `AllowedHashes` are never read — not here, and nowhere else in the package. Setting `RequireSignature: true` and calling `Validate` gives you a size check and nothing else, while reading like an enforced signature requirement. `TestSecurityPolicy` asserts exactly this and no more: a 1 KiB module passes, an 11 MiB module fails. **Do not use `SecurityPolicy` as a gate.** Sequence the checks yourself: ```go if err := policy.Validate(moduleBytes); err != nil { // size only return err } if err := hashes.VerifyHash(name, moduleBytes); err != nil { return err } if err := signatures.VerifyWithKey(signerID, moduleBytes, sig); err != nil { return err } ``` ::: ## `VerificationError` A structured error type for reporting a failed check. It is exported and its `Error()` renders module, reason, expected and actual hash — but **no function in the package returns it**. Every failure path uses `fmt.Errorf` instead. Use it in your own verification wrapper if you want typed errors: ```go return &wasm.VerificationError{ Module: name, ExpectedHash: expected, ActualHash: verifier.ComputeHash(moduleBytes), Reason: "pinned hash mismatch", } ``` ## Caveats summary :::info[What the tests cover] `wasm/signer_test.go` and `wasm/verifier_test.go` are reasonably thorough for this package: signer construction and key-size validation, signing and tamper detection, trusted-key add/remove/list, `SignModule`/`VerifySignedModule`, manifest creation, `VerifyWithManifest` including hash mismatch and expiry, manifest JSON round trip, hash computation and pinning, fallback verification, hash chains including a broken chain, `SecurityPolicy` size limits, and `VerificationError` formatting. What they do not cover is the *semantics* of the gaps above: no test asserts that `RequireSignature: true` is enforced (it is not), or that `VerifyWithManifest` establishes trust (it does not), or that a fallback match leaves the pin set unchanged (it does not). ::: ## Next Encrypting a signing key at rest. Ed25519's siblings, and when a different signature scheme fits better. Every stub and defect in the module, in one place. How this fits with enclaves, DIDs and capability tokens.