Aegis.X
Aegis  /  Docs  /  Multisig

Getting the transaction to the other devices.

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.

01 — The problem

Why a transaction has to travel

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.

02 — Addressing

The channel, with nothing to pair

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.

03 — Confidentiality

The envelope

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.

04 — Payloads

The three messages

Inside the envelope, one of three. pst is your format, untouched.

kindfieldsmeans
requestat, txId, from, note, pstHere is a transaction. Please look at it.
signedat, txId, from, pstHere it is with my signature added.
declinedat, txId, from, reasonI 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.

Declining is a message, not silence

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.

05 — Delivery

The transport, and the relay one

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:

DetailValueWhy
Event kind1059NIP-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 tagpThe tag relays index for addressing. The channel id goes here, which is why it is 32 bytes.
Signing keyper sessionGenerated fresh, never reused, so the relay cannot link two requests to the same device.
Lookback24 hA device that was asleep when the request went out still finds it. Kind 1059 is stored, not ephemeral.
Dedupeby event idSeveral relays deliver the same event; one request should appear once.
If you use nostr-tools

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.

06 — Limits

What this is not

It never decides whether to sign

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.

The channel is not an authorisation boundary

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.

What a relay learns

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.

07 — Implementing

Adopting it in your wallet

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.

08 — Checking your work

Test vector

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"}}

Constants

NameValue
Protocol stringaegis.cosign.v1
HKDF saltaegis.cosign.v1/salt
HKDF infochannel-id  |  channel-key
CipherAES-256-GCM, 96-bit nonce, aad = utf8(channel id)
Maximum message age86,400,000 ms
Nostr event kind1059
Nostr address tagp