An m-of-n wallet needs m signatures from m devices, so the transaction has to travel. Most wallets make you carry it: copy the file, email it, paste it back. This is an open protocol for delivering it instead — two files, no pairing step, no server, and no wallet lock-in. It is what Aegis uses, and it is specified here so any wallet can implement it and interoperate.
A multisig address is locked by a script requiring m signatures. No single device can produce them, so a transaction is built once, signed by m devices in turn, and only then broadcast. In between it is a partially-signed transaction: a thing that exists, is incomplete, and has to get somewhere.
Carrying it by hand works and is honest. What it does not do is tell anybody: the second device has no idea a signature is wanted until its owner happens to look. This protocol is that notification, and nothing more. It moves an opaque blob and never looks inside it, so your wallet's own partially-signed format — PSBT, Electrum's, your own — travels unchanged.
The channel is derived from the co-signer set itself. Every device holding the wallet computes the same one, so there is no invite to scan, no code to exchange and no key agreement before the first message. Devices that share a wallet are already addressing the same place.
Canonical JSON, with the extended public keys sorted so two devices listing them in different orders still meet. Field order is part of the spec:
// the seed, UTF-8 encoded {"p":"aegis.cosign.v1","chain":"bch","network":"chipnet","m":2,"xpubs":[…sorted…]}
Then one HKDF, split by info string:
HKDF-SHA256 salt = "aegis.cosign.v1/salt" ikm = the seed above channel id info = "channel-id" 32 bytes, hex — public, the relay routes on it channel key info = "channel-key" 32 bytes — AES-256-GCM, never leaves the device
Both come from the same seed but neither reveals the other, so publishing the id — which a relay must see — tells nobody the key.
AES-256-GCM with a fresh 96-bit nonce per message. The channel id is authenticated but not encrypted, because a relay has to read it to route — binding it in as associated data means a message cannot be lifted onto a different channel.
{
"p": "aegis.cosign.v1",
"ch": channel id, 64 hex,
"iv": 12 random bytes, base64,
"ct": AES-256-GCM(key, iv, plaintext, aad = utf8(ch)), base64
}
A receiver drops anything whose p is not the protocol, whose
ch is not its own channel, or that fails to decrypt. Failing to
decrypt and being tampered with give the same error on purpose —
telling an attacker which of the two they achieved is free information.
Messages carry at, a millisecond timestamp, and anything older
than 24 hours is refused. A co-sign request is a live conversation;
one that surfaces a day later is a replay, not news.
Inside the envelope, one of three. pst is your format, untouched.
| kind | fields | means |
|---|---|---|
| request | at, txId, from, note, pst | Here is a transaction. Please look at it. |
| signed | at, txId, from, pst | Here it is with my signature added. |
| declined | at, txId, from, reason | I looked, and no. |
from and note are free text for the person reading
it, capped at 200 characters, and are not trusted for anything.
txId lets a device tell two live requests apart without
decrypting twice.
A refusal that reaches the asker is worth sending. Without it, "no" and "my phone is in a drawer" look identical, and the person waiting cannot tell whether to go and find another co-signer.
The protocol needs two functions. Anything satisfying this works:
publish(channelId, envelope) // -> Promise<any> subscribe(channelId, handler) // -> Promise<unsubscribe>
A websocket, a polled bucket, a shared folder, a QR code you photograph. Aegis ships one over Nostr relays, which is worth specifying because the details are not guessable and cost real time to discover:
| Detail | Value | Why |
|---|---|---|
| Event kind | 1059 | NIP-59 gift wrap. The relays reject other kinds outright — event kind is blocked by relay. It fits anyway: an opaque blob signed by a throwaway key, addressed to someone. |
| Address tag | p | The tag relays index for addressing. The channel id goes here, which is why it is 32 bytes. |
| Signing key | per session | Generated fresh, never reused, so the relay cannot link two requests to the same device. |
| Lookback | 24 h | A device that was asleep when the request went out still finds it. Kind 1059 is stored, not ephemeral. |
| Dedupe | by event id | Several relays deliver the same event; one request should appear once. |
subscribeMany takes a single filter object, not an array
of them. The array form produces a nested filter that relays answer with
could not parse command and then an empty EOSE —
a subscription that looks established and silently matches nothing, while
publishing succeeds the whole time.
The Nostr signature proves only that somebody published. It is not what makes a message trustworthy, and nothing in this protocol treats it as though it were.
A request arriving on the channel is an invitation to look. The receiving wallet re-derives the transaction against keys it already holds and shows its owner what it found. A relay cannot make a device sign, and neither can this protocol. If it could, it would be a worse version of the problem it solves.
Anyone who knows the extended public keys can derive the channel. That is the point — co-signers know them — but it means the channel is only as private as the co-signer set. Anyone holding those keys could already watch every address on chain, so this adds no exposure that was not there. It is still worth saying out loud.
Encryption keeps relays from reading traffic; it does not make a request trustworthy. Your signing layer is what makes a forged request harmless, by checking every transaction against its own copy of the wallet before showing anything. Build that first; this is defence in depth, in that order.
That a channel id exists, roughly how often it is busy, and how large the messages are. Not what they say, not which wallet they belong to, not who the co-signers are.
Two files, no build step, no package to install. cosign.js
needs only WebCrypto; cosign-relay.js takes its relay pool,
event signer and relay list as arguments, so it carries no dependency of
its own. Both are MPL-2.0.
// 1. open the channel when the wallet loads const cosign = makeCosign(); const relay = makeCosignRelay({ pool, finalizeEvent, generateSecretKey, relays }); const session = await cosign.session( { chain: "bch", network: "chipnet", m: 2, xpubs }, // the wallet, that is all relay, { onMessage: (msg) => inbox.add(msg), // list it; never act on it onError: (e) => log(e) }); // unreadable traffic is normal // 2. ask the others to sign await session.ask({ pst, txId: pst.id, from: "laptop", note: "rent" }); // 3. answer, after YOUR wallet has checked and the owner has agreed await session.reply({ pst: signedPst, txId, from: "phone" }); await session.decline({ txId, from: "phone", reason: "not expecting this" });
Implementing against the spec rather than copying the files is equally fine, and is why the derivation, the envelope and the vector below are written out exactly. A wallet in another language interoperates by producing the same bytes.
Source: code.silentmode.st/silentmode/aegis
— lib/cosign.js and lib/cosign-relay.js.
A 2-of-3 on chipnet. If your implementation produces this channel id from these keys, your derivation matches.
xpubs (any order) xpub6DMExqxuPUeWryR35SXtvwyqAMkDKUUTmnSAaYkHmgJ2z6rtwMNUedsJ7BTtMX4q4PUVHY7MpKXoSvz8tvDnFk8v4LNnNmH9ErEjUdV4npp xpub6CoQeu32piSNVz7NP67PUoo4hqQpKsi6opvhAWqfkyHoNG6FRfyCZ5f9AN1xEqbNo2ripAFd3j7yj1xJVvvGKVmQq9NNwc9DREuf9Gngius xpub6D1gTpfGtFkL8t2yKgoEKDFRwseCsgGqjSb119sR4wzsqtQU8pGqGGqu12dVNEYnH7Mv79CJYLg8biy3Jt7wKcFG83DYi6GhNQV6ifyLEvq chain "bch", network "chipnet", m = 2 channel id 8cca5fb6eedbe5764a8de31e8ccc5298a0dadb81c7ee33b5a393fe48c803174c
And an envelope, sealed with an all-0x2a nonce so it is
reproducible. Real ones use a random nonce.
{
"p": "aegis.cosign.v1",
"ch": "8cca5fb6eedbe5764a8de31e8ccc5298a0dadb81c7ee33b5a393fe48c803174c",
"iv": "KioqKioqKioqKioq",
"ct": "1xbh1I9lcw1cbhPCv1tXFgLvbEE/ci2Zdo8+Iga2xo0/i3/sbTXWTxKB0nEisLVvvclIzyeRtS3vmx3tbS8thDkMm5hKdZRGAHftogjVxbYpTjdFPDQ/lKLBnfRWRuG/lLj2T7nqAJ80+ck0AAvX7be3vm55uQ=="
}
opens to
{"kind":"request","at":1700000000000,"txId":"abc","from":"laptop","note":null,"pst":{"hello":"world"}}
| Name | Value |
|---|---|
| Protocol string | aegis.cosign.v1 |
| HKDF salt | aegis.cosign.v1/salt |
| HKDF info | channel-id | channel-key |
| Cipher | AES-256-GCM, 96-bit nonce, aad = utf8(channel id) |
| Maximum message age | 86,400,000 ms |
| Nostr event kind | 1059 |
| Nostr address tag | p |