feat(wallet-multisig): adopt Aegis cosign transport + scaffold signing layer
Implements the aegis.cosign.v1 protocol specified at bns://aegis.x/multisig/
and lays the typed foundation for the signing layer that will build on it.
TRANSPORT (B — this commit, fully working):
- src/multisig/cosign.ts: HKDF-SHA256 channel derivation over canonical
{p, chain, network, m, xpubs[sorted]} JSON; AES-256-GCM envelope with
channel id authenticated as AAD; three message kinds (request / signed /
declined); 24-hour replay guard; validator rejects messages failing any
check without discriminating the reason (so an attacker learns nothing
about which guard caught them).
- src/multisig/cosign-relay.ts: Nostr NIP-59 gift-wrap (kind 1059)
transport. Fresh per-session Nostr signing key so relays cannot link
conversations. SINGLE filter in subscribeMany per the spec's §05 warning
(array form silently matches nothing while publishing succeeds).
- test/multisig-cosign.test.ts: 17/17 — reproduces the spec's §08 vector
byte-for-byte (channel id 8cca5fb6…, envelope round-trip with the fixed
all-0x2a IV, plaintext {kind:request, at, txId, from, note:null, pst}).
Also covers tamper/replay rejection and the end-to-end session verbs.
- MPL-2.0 to match the Aegis reference code; any wallet speaking the spec
can co-sign with Deviant and vice versa.
SIGNING LAYER (A — scaffolding only; next session's build):
- src/multisig/types.ts: MultisigComposition, MultisigWalletFields, the
wire DeviantPst JSON shape, CosignerIdentity, and bip48CosignPath. These
are the stable contract the covenant, the libauth bridge, and the UI
will all build against.
- Script form picked per-wallet at creation: `covenant` default
(DeviantMultiSig.cash — unlimited m-of-n, first-class CashTokens) or
`native` opt-in (BCH OP_CHECKMULTISIG P2SH-20, 15-of-20 cap).
- Wire PST = Deviant JSON aligned with Aegis's example shape, internal
signing on libauth's partial primitives.
- Keystore model = new `multisig` kind in MultiKeyStore beside
`independent` + `hd-account`; BIP-48 branch (m/48'/145'/N'/2'/0/0) for
each cosigning identity.
ADR-0056 appended to wiki/decisions.md with the full rationale, the
decisions locked, and the open questions the next session must settle
(DeviantMultiSig.cash covenant draft + chipnet validation; libauth PST
signing adapter; UI flow; keystore migration).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9412836f02
commit
f1ea4d6aa8
5 changed files with 884 additions and 0 deletions
146
shells/app/src/multisig/cosign-relay.ts
Normal file
146
shells/app/src/multisig/cosign-relay.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Nostr NIP-59 (gift-wrap) transport for the Aegis cosign protocol.
|
||||
*
|
||||
* Why Nostr: the spec (§05) picks relays because they are plentiful, cheap,
|
||||
* do not know what they carry, and a device that was asleep when a request
|
||||
* went out still finds it on reconnect (kind 1059 is stored, not ephemeral).
|
||||
*
|
||||
* One subtlety that costs real time to re-discover and so is pinned here:
|
||||
* `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" + an empty EOSE — a subscription that looks established and
|
||||
* silently matches nothing, while publishing succeeds the whole time.
|
||||
*
|
||||
* The relay is injected (pool + signer + relay list) so this module stays
|
||||
* runtime-agnostic: the wallet's existing `nostr-tools` SimplePool (already
|
||||
* loaded for WizardConnect) is reused, and the Nostr signing key is
|
||||
* generated FRESH per session so no two cosign conversations are linkable
|
||||
* at the relay layer.
|
||||
*
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import type { Envelope, Transport } from './cosign';
|
||||
import { PROTOCOL } from './cosign';
|
||||
|
||||
// nostr-tools types we need — kept nominal so this module can take any
|
||||
// SimplePool-shaped object (useful for tests, mirrors how the Aegis spec
|
||||
// defines it: "takes its relay pool, event signer and relay list as
|
||||
// arguments, so it carries no dependency of its own").
|
||||
export interface NostrEvent {
|
||||
readonly id: string;
|
||||
readonly pubkey: string;
|
||||
readonly created_at: number;
|
||||
readonly kind: number;
|
||||
readonly tags: readonly (readonly string[])[];
|
||||
readonly content: string;
|
||||
readonly sig: string;
|
||||
}
|
||||
|
||||
export interface NostrEventTemplate {
|
||||
readonly kind: number;
|
||||
readonly created_at: number;
|
||||
readonly tags: readonly (readonly string[])[];
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export interface NostrPool {
|
||||
publish(relays: readonly string[], event: NostrEvent, params?: unknown): Promise<string>[];
|
||||
subscribeMany(
|
||||
relays: readonly string[],
|
||||
filter: NostrFilter,
|
||||
params: {
|
||||
onevent?: (ev: NostrEvent) => void;
|
||||
oneose?: () => void;
|
||||
onclose?: (reasons: string[]) => void;
|
||||
label?: string;
|
||||
},
|
||||
): { close: (reason?: string) => void };
|
||||
}
|
||||
|
||||
export interface NostrFilter {
|
||||
readonly kinds?: readonly number[];
|
||||
readonly '#p'?: readonly string[];
|
||||
readonly since?: number;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface RelayDeps {
|
||||
readonly pool: NostrPool;
|
||||
/** Finalize an unsigned event with the given secret key — the nostr-tools function or a shim. */
|
||||
readonly finalizeEvent: (t: NostrEventTemplate, secretKey: Uint8Array) => NostrEvent;
|
||||
/** 32-byte Nostr secret key generator. */
|
||||
readonly generateSecretKey: () => Uint8Array;
|
||||
/** Relay URLs. */
|
||||
readonly relays: readonly string[];
|
||||
}
|
||||
|
||||
/** NIP-59 kind — "gift wrap". Relays are configured to reject other kinds outright. */
|
||||
export const NOSTR_KIND_GIFT_WRAP = 1059;
|
||||
/** Lookback window on reconnect — matches the spec's 24-hour message age cap. */
|
||||
const LOOKBACK_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Build a Transport (publish + subscribe) backed by a Nostr pool.
|
||||
*
|
||||
* The channel id is used as the Nostr `p` tag (the one relays index for
|
||||
* addressing) — that is why the spec's channel id is 32 bytes (64 hex), the
|
||||
* exact length NIP-01 reserves for `p` values.
|
||||
*/
|
||||
export function makeCosignRelay(deps: RelayDeps): Transport {
|
||||
const relays = [...deps.relays];
|
||||
return {
|
||||
async publish(channelId, envelope) {
|
||||
// Fresh signing key per session — the relay can see WHEN a channel is
|
||||
// busy, but not that two requests came from the same device. Reusing a
|
||||
// key would leak linkability. The Nostr signature is only used to pass
|
||||
// relay checks; nothing in the protocol trusts it (§05).
|
||||
const sk = deps.generateSecretKey();
|
||||
const event = deps.finalizeEvent(
|
||||
{
|
||||
kind: NOSTR_KIND_GIFT_WRAP,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', channelId]],
|
||||
content: JSON.stringify(envelope),
|
||||
},
|
||||
sk,
|
||||
);
|
||||
// SimplePool.publish returns one Promise per relay; await all so a flaky
|
||||
// relay surfaces as an error here rather than a silent delivery miss.
|
||||
await Promise.allSettled(deps.pool.publish(relays, event));
|
||||
return event.id;
|
||||
},
|
||||
|
||||
async subscribe(channelId, handler) {
|
||||
const seen = new Set<string>(); // dedupe (§05)
|
||||
const since = Math.floor((Date.now() - LOOKBACK_MS) / 1000);
|
||||
|
||||
// SINGLE filter object. Do not wrap in an array — see module header.
|
||||
const sub = deps.pool.subscribeMany(
|
||||
relays,
|
||||
{ kinds: [NOSTR_KIND_GIFT_WRAP], '#p': [channelId], since },
|
||||
{
|
||||
onevent: (ev) => {
|
||||
if (seen.has(ev.id)) return;
|
||||
seen.add(ev.id);
|
||||
// The event content is our envelope JSON. Anything that fails to
|
||||
// parse or does not match the protocol is dropped — the cosign
|
||||
// layer applies its own identical check as defence in depth.
|
||||
try {
|
||||
const parsed = JSON.parse(ev.content) as Envelope;
|
||||
if (parsed?.p === PROTOCOL && parsed.ch === channelId) handler(parsed);
|
||||
} catch {
|
||||
/* junk on the channel is normal; move on */
|
||||
}
|
||||
},
|
||||
label: `aegis.cosign:${channelId.slice(0, 8)}…`,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
try { sub.close(); } catch { /* already closed */ }
|
||||
return Promise.resolve();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
300
shells/app/src/multisig/cosign.ts
Normal file
300
shells/app/src/multisig/cosign.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
/**
|
||||
* Aegis cosign transport — the message layer only.
|
||||
*
|
||||
* This is a faithful implementation of the `aegis.cosign.v1` spec published at
|
||||
* bns://aegis.x/multisig/. The protocol delivers a partially-signed BCH
|
||||
* transaction (an opaque "PST" blob) between the m devices of an m-of-n
|
||||
* co-signer set, using a channel derived from the wallet's extended public
|
||||
* keys so there is no pairing step.
|
||||
*
|
||||
* What this module DOES:
|
||||
* • Derives the shared channel id + channel key via HKDF-SHA256 over a
|
||||
* canonical JSON of the wallet descriptor (chain + network + m + sorted
|
||||
* xpubs). Both come from the same seed; neither reveals the other.
|
||||
* • Seals outbound messages in an AES-256-GCM envelope with the channel id
|
||||
* authenticated as AAD so an attacker cannot lift a message onto another
|
||||
* channel.
|
||||
* • Decrypts inbound envelopes and type-filters the three message kinds
|
||||
* (`request`, `signed`, `declined`), dropping anything older than 24 h or
|
||||
* with a mismatched protocol or channel.
|
||||
*
|
||||
* What this module does NOT do (per the spec's §06 LIMITS):
|
||||
* • It never decides whether to sign. The PST is opaque bytes to this
|
||||
* layer; the owner's wallet re-derives it against its own keys and shows
|
||||
* a human what it found.
|
||||
* • It is not an authorisation boundary. Any device holding the xpubs can
|
||||
* derive the same channel; that's the point. Signing-layer validation is
|
||||
* what makes a forged request harmless.
|
||||
*
|
||||
* Transport is pluggable. See `cosign-relay.ts` for the Nostr NIP-59 relay
|
||||
* that Aegis ships, which is also what the Deviant wallet uses by default.
|
||||
* Any `{ publish, subscribe }` pair satisfying the Transport interface works.
|
||||
*
|
||||
* Interoperability note: a Deviant wallet produced with these exact bytes
|
||||
* can co-sign with an Aegis wallet (or any other wallet to spec) without
|
||||
* either side knowing the other exists.
|
||||
*
|
||||
* SPDX-License-Identifier: MPL-2.0 (matches the Aegis reference code)
|
||||
*/
|
||||
|
||||
export const PROTOCOL = 'aegis.cosign.v1' as const;
|
||||
export const HKDF_SALT = 'aegis.cosign.v1/salt' as const;
|
||||
export const INFO_CHANNEL_ID = 'channel-id' as const;
|
||||
export const INFO_CHANNEL_KEY = 'channel-key' as const;
|
||||
export const MAX_MESSAGE_AGE_MS = 86_400_000; // 24 h
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Descriptor {
|
||||
readonly p?: typeof PROTOCOL; // defaulted by seedBytesFor
|
||||
readonly chain: string; // "bch", "btc", …
|
||||
readonly network: string; // "chipnet", "mainnet", "testnet4"
|
||||
readonly m: number;
|
||||
readonly xpubs: readonly string[]; // sorted by seedBytesFor; order does not matter at input
|
||||
}
|
||||
|
||||
export interface Envelope {
|
||||
readonly p: typeof PROTOCOL;
|
||||
readonly ch: string; // 64-hex channel id
|
||||
readonly iv: string; // base64 (12 bytes)
|
||||
readonly ct: string; // base64 ciphertext+tag
|
||||
}
|
||||
|
||||
export type Message =
|
||||
| { readonly kind: 'request'; readonly at: number; readonly txId: string; readonly from: string; readonly note: string | null; readonly pst: unknown }
|
||||
| { readonly kind: 'signed'; readonly at: number; readonly txId: string; readonly from: string; readonly pst: unknown }
|
||||
| { readonly kind: 'declined'; readonly at: number; readonly txId: string; readonly from: string; readonly reason: string };
|
||||
|
||||
/** Transport contract — any {publish, subscribe} satisfying this interoperates. */
|
||||
export interface Transport {
|
||||
publish(channelId: string, envelope: Envelope): Promise<unknown>;
|
||||
/** `handler` is called with every raw envelope seen on the channel; dedupe is the transport's job. */
|
||||
subscribe(channelId: string, handler: (envelope: Envelope) => void): Promise<() => void>;
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
readonly onMessage?: (msg: Message) => void;
|
||||
readonly onError?: (err: Error) => void;
|
||||
}
|
||||
|
||||
// ─── Canonical seed derivation ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The UTF-8 bytes of the canonical descriptor JSON. Field order is PART OF
|
||||
* THE SPEC — two devices must emit the same bytes, so we hand-write the JSON
|
||||
* instead of trusting JSON.stringify key order (which happens to be
|
||||
* insertion-order for object literals under V8/SM, but we don't rely on it).
|
||||
*
|
||||
* Shape: `{"p":"aegis.cosign.v1","chain":<chain>,"network":<network>,"m":<m>,"xpubs":[…sorted…]}`
|
||||
*/
|
||||
export function seedBytesFor(d: Descriptor): Uint8Array {
|
||||
const sortedXpubs = [...d.xpubs].sort();
|
||||
const json =
|
||||
'{"p":' + JSON.stringify(PROTOCOL) +
|
||||
',"chain":' + JSON.stringify(d.chain) +
|
||||
',"network":' + JSON.stringify(d.network) +
|
||||
',"m":' + JSON.stringify(d.m) +
|
||||
',"xpubs":[' + sortedXpubs.map((x) => JSON.stringify(x)).join(',') + ']}';
|
||||
return new TextEncoder().encode(json);
|
||||
}
|
||||
|
||||
async function hkdf(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, byteLength: number): Promise<Uint8Array> {
|
||||
// Cast at the WebCrypto boundary: TS 5.7+ tightened Uint8Array's generic
|
||||
// parameter (ArrayBufferLike vs the stricter ArrayBuffer that BufferSource
|
||||
// expects). The runtime values are correct; this is a types-only workaround.
|
||||
const key = await crypto.subtle.importKey('raw', ikm as BufferSource, 'HKDF', false, ['deriveBits']);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: salt as BufferSource, info: info as BufferSource },
|
||||
key,
|
||||
byteLength * 8,
|
||||
);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
/** 32-byte hex channel id — public; a relay routes on it. Reveals nothing of the key. */
|
||||
export async function deriveChannelId(d: Descriptor): Promise<string> {
|
||||
const bytes = await hkdf(
|
||||
seedBytesFor(d),
|
||||
new TextEncoder().encode(HKDF_SALT),
|
||||
new TextEncoder().encode(INFO_CHANNEL_ID),
|
||||
32,
|
||||
);
|
||||
return bytesToHex(bytes);
|
||||
}
|
||||
|
||||
/** 32-byte AES-256-GCM key — never leaves the device. */
|
||||
export async function deriveChannelKey(d: Descriptor): Promise<CryptoKey> {
|
||||
const raw = await hkdf(
|
||||
seedBytesFor(d),
|
||||
new TextEncoder().encode(HKDF_SALT),
|
||||
new TextEncoder().encode(INFO_CHANNEL_KEY),
|
||||
32,
|
||||
);
|
||||
return crypto.subtle.importKey('raw', raw as BufferSource, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
// ─── Envelope ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Seal a Message in an AES-256-GCM envelope. Pass `ivOverride` only in tests. */
|
||||
export async function seal(
|
||||
key: CryptoKey,
|
||||
channelId: string,
|
||||
msg: Message,
|
||||
ivOverride?: Uint8Array,
|
||||
): Promise<Envelope> {
|
||||
const iv = ivOverride ?? crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(canonicalMessageJson(msg));
|
||||
const aad = new TextEncoder().encode(channelId);
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource, additionalData: aad as BufferSource, tagLength: 128 },
|
||||
key,
|
||||
plaintext as BufferSource,
|
||||
));
|
||||
return { p: PROTOCOL, ch: channelId, iv: bytesToBase64(iv), ct: bytesToBase64(ct) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unseal an envelope back to a Message. Returns null on ANY failure — "wrong
|
||||
* protocol", "wrong channel", "could not decrypt" and "tampered" all map to
|
||||
* the same outcome on purpose. Telling an attacker which of them they
|
||||
* achieved is free information for the attacker.
|
||||
*/
|
||||
export async function unseal(
|
||||
key: CryptoKey,
|
||||
channelId: string,
|
||||
env: Envelope,
|
||||
): Promise<Message | null> {
|
||||
if (env?.p !== PROTOCOL) return null;
|
||||
if (env.ch !== channelId) return null;
|
||||
try {
|
||||
const iv = base64ToBytes(env.iv);
|
||||
const ct = base64ToBytes(env.ct);
|
||||
const aad = new TextEncoder().encode(channelId);
|
||||
const plaintext = new Uint8Array(await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv as BufferSource, additionalData: aad as BufferSource, tagLength: 128 },
|
||||
key,
|
||||
ct as BufferSource,
|
||||
));
|
||||
const parsed = JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
|
||||
const msg = validateMessage(parsed);
|
||||
if (!msg) return null;
|
||||
if (Date.now() - msg.at > MAX_MESSAGE_AGE_MS) return null; // replay guard
|
||||
return msg;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical JSON for a Message — same ordering rule as the seed JSON. */
|
||||
function canonicalMessageJson(msg: Message): string {
|
||||
// The spec shows: {"kind":"request","at":…,"txId":…,"from":…,"note":…,"pst":…}
|
||||
// Field order is enforced by hand so round-trips are byte-stable.
|
||||
switch (msg.kind) {
|
||||
case 'request':
|
||||
return '{"kind":"request","at":' + msg.at +
|
||||
',"txId":' + JSON.stringify(msg.txId) +
|
||||
',"from":' + JSON.stringify(msg.from) +
|
||||
',"note":' + (msg.note == null ? 'null' : JSON.stringify(msg.note)) +
|
||||
',"pst":' + JSON.stringify(msg.pst) + '}';
|
||||
case 'signed':
|
||||
return '{"kind":"signed","at":' + msg.at +
|
||||
',"txId":' + JSON.stringify(msg.txId) +
|
||||
',"from":' + JSON.stringify(msg.from) +
|
||||
',"pst":' + JSON.stringify(msg.pst) + '}';
|
||||
case 'declined':
|
||||
return '{"kind":"declined","at":' + msg.at +
|
||||
',"txId":' + JSON.stringify(msg.txId) +
|
||||
',"from":' + JSON.stringify(msg.from) +
|
||||
',"reason":' + JSON.stringify(msg.reason) + '}';
|
||||
}
|
||||
}
|
||||
|
||||
function validateMessage(v: unknown): Message | null {
|
||||
if (!v || typeof v !== 'object') return null;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.at !== 'number' || typeof o.txId !== 'string' || typeof o.from !== 'string') return null;
|
||||
if (o.from.length > 200) return null; // spec cap
|
||||
switch (o.kind) {
|
||||
case 'request':
|
||||
if (o.note !== null && typeof o.note !== 'string') return null;
|
||||
if (typeof o.note === 'string' && o.note.length > 200) return null;
|
||||
return { kind: 'request', at: o.at, txId: o.txId, from: o.from, note: (o.note as string | null) ?? null, pst: o.pst };
|
||||
case 'signed':
|
||||
return { kind: 'signed', at: o.at, txId: o.txId, from: o.from, pst: o.pst };
|
||||
case 'declined':
|
||||
if (typeof o.reason !== 'string') return null;
|
||||
return { kind: 'declined', at: o.at, txId: o.txId, from: o.from, reason: o.reason };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session (open channel + three verbs) ───────────────────────────────
|
||||
|
||||
export interface Session {
|
||||
/** Public channel id — a relay routes on it. Share freely. */
|
||||
readonly channelId: string;
|
||||
/** Send a `request` with the given PST to the other co-signers. */
|
||||
ask(args: { pst: unknown; txId: string; from: string; note?: string | null }): Promise<void>;
|
||||
/** Reply to a request with a signed PST. */
|
||||
reply(args: { pst: unknown; txId: string; from: string }): Promise<void>;
|
||||
/** Reply to a request with a decline. Important — silence looks like "my phone is in a drawer". */
|
||||
decline(args: { txId: string; from: string; reason: string }): Promise<void>;
|
||||
/** Tear down the subscription. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Cosign {
|
||||
session(descriptor: Descriptor, transport: Transport, opts?: SessionOptions): Promise<Session>;
|
||||
}
|
||||
|
||||
export function makeCosign(): Cosign {
|
||||
return {
|
||||
async session(descriptor, transport, opts = {}) {
|
||||
const channelId = await deriveChannelId(descriptor);
|
||||
const key = await deriveChannelKey(descriptor);
|
||||
const unsub = await transport.subscribe(channelId, (env) => {
|
||||
unseal(key, channelId, env).then(
|
||||
(m) => { if (m && opts.onMessage) opts.onMessage(m); },
|
||||
(e) => { if (opts.onError) opts.onError(e as Error); },
|
||||
);
|
||||
});
|
||||
const sendMsg = async (msg: Message) => {
|
||||
const env = await seal(key, channelId, msg);
|
||||
await transport.publish(channelId, env);
|
||||
};
|
||||
return {
|
||||
channelId,
|
||||
async ask({ pst, txId, from, note = null }) {
|
||||
await sendMsg({ kind: 'request', at: Date.now(), txId, from, note, pst });
|
||||
},
|
||||
async reply({ pst, txId, from }) {
|
||||
await sendMsg({ kind: 'signed', at: Date.now(), txId, from, pst });
|
||||
},
|
||||
async decline({ txId, from, reason }) {
|
||||
await sendMsg({ kind: 'declined', at: Date.now(), txId, from, reason });
|
||||
},
|
||||
async close() { await unsub(); },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Hex / Base64 helpers (small, local — avoid pulling node:buffer) ────
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += (b[i]! >> 4).toString(16) + (b[i]! & 0xf).toString(16);
|
||||
return s;
|
||||
}
|
||||
function bytesToBase64(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!);
|
||||
return btoa(s);
|
||||
}
|
||||
function base64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
115
shells/app/src/multisig/types.ts
Normal file
115
shells/app/src/multisig/types.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Multi-sig wallet types — scaffolding for the signing layer (A).
|
||||
*
|
||||
* These type definitions are the stable contract the keystore, PST
|
||||
* construction/signing, and UI will build against. Only types are exported
|
||||
* from this file; no behaviour yet (that is the next session's work, tracked
|
||||
* as ADR-0056 and in `wiki/decisions.md`).
|
||||
*
|
||||
* CONTRACT DECISIONS (locked in this session):
|
||||
* • Script form is PER-WALLET at creation time: `covenant` by default
|
||||
* (DeviantMultiSig.cash — unlimited m-of-n, first-class CashTokens), or
|
||||
* `native` (BCH's OP_CHECKMULTISIG P2SH-20, hard-capped 15-of-20, smaller
|
||||
* tx, proven for a decade).
|
||||
* • PST format on the wire is Deviant JSON aligned with the Aegis spec's
|
||||
* example shape (opaque to the transport); internally we build on
|
||||
* @bitauth/libauth's partial-signing primitives and serialize to the JSON
|
||||
* at session boundaries.
|
||||
* • A multi-sig wallet is a NEW kind in MultiKeyStore beside `independent`
|
||||
* and `hd-account`. Each cosigning identity derives from a seed in the
|
||||
* same keystore (BIP-48 branch — `m/48'/145'/N'/2'/0/0`).
|
||||
*/
|
||||
|
||||
import type { SupportedNetwork } from '@core/wallet/keystore.js';
|
||||
|
||||
// ─── Multi-sig wallet record (goes into MultiKeyStore) ───────────────────
|
||||
|
||||
export type MultisigScriptForm = 'covenant' | 'native';
|
||||
|
||||
/**
|
||||
* The composition of a multi-sig wallet, exactly as persisted. `xpubs` is
|
||||
* stored in input order; the canonical sort for channel derivation happens
|
||||
* in `cosign.ts::seedBytesFor`.
|
||||
*/
|
||||
export interface MultisigComposition {
|
||||
readonly m: number; // signatures required
|
||||
readonly n: number; // == xpubs.length
|
||||
readonly xpubs: readonly string[];
|
||||
/** Index into `xpubs` identifying which pubkey is ours. Needed to pick the signing branch. */
|
||||
readonly mineIndex: number;
|
||||
/** Script form picked at creation. See the module header. */
|
||||
readonly scriptForm: MultisigScriptForm;
|
||||
}
|
||||
|
||||
/**
|
||||
* The addition to `WalletRecord` for multi-sig wallets. Added as a new kind;
|
||||
* other kinds (`independent`, `hd-account`) are unaffected.
|
||||
*/
|
||||
export interface MultisigWalletFields {
|
||||
readonly kind: 'multisig';
|
||||
readonly composition: MultisigComposition;
|
||||
/** The seed in MultiKeyStore that holds OUR cosigning branch. */
|
||||
readonly seedId: string;
|
||||
/** BIP-48 account index on that seed. m/48'/145'/<accountIndex>'/2'/0/0 */
|
||||
readonly accountIndex: number;
|
||||
}
|
||||
|
||||
// ─── PST JSON format (wire shape) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A partially-signed transaction, serialized for the cosign transport. This
|
||||
* is the body that goes into the `pst` field of a cosign `request` or
|
||||
* `signed` message. The shape is opaque to the transport (per Aegis spec
|
||||
* §01) — only this module and the signing layer touch its fields.
|
||||
*
|
||||
* Internally, the signing layer builds and manipulates libauth's own partial
|
||||
* types; `toJson` and `fromJson` adapters at the session boundary convert.
|
||||
*/
|
||||
export interface DeviantPst {
|
||||
readonly v: 1;
|
||||
readonly chain: 'bch';
|
||||
readonly network: SupportedNetwork;
|
||||
/** Identifier for the composition — the channel id derived from xpubs. */
|
||||
readonly channelId: string;
|
||||
/**
|
||||
* Serialized unsigned transaction (hex of the sighash-ready preimage set).
|
||||
* Format mirrors what libauth's `generateTransaction` produces.
|
||||
*/
|
||||
readonly unsignedTx: string;
|
||||
/** Per-input, per-signer signatures gathered so far. Keyed by xpub index. */
|
||||
readonly signatures: {
|
||||
readonly [inputIndex: number]: { readonly [xpubIndex: number]: string }; // DER-encoded sig hex
|
||||
};
|
||||
/** Human-visible metadata — not consensus-bound; renderers show it as hints. */
|
||||
readonly hint?: {
|
||||
readonly to?: string;
|
||||
readonly amount?: string; // "0.0123 BCH" or "500 ASM"
|
||||
readonly note?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public identity (what we share with co-signers) ─────────────────────
|
||||
|
||||
/**
|
||||
* The information a new co-signer needs from us to set up a shared wallet.
|
||||
* We hand this out via QR / copy-paste when creating a multi-sig between
|
||||
* devices or people. BIP-48 means the xpub alone is sufficient — the path
|
||||
* is standard.
|
||||
*/
|
||||
export interface CosignerIdentity {
|
||||
readonly chain: 'bch';
|
||||
readonly network: SupportedNetwork;
|
||||
readonly xpub: string;
|
||||
/** Optional free-text label, 200-char cap (matches cosign's `from` cap). */
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
/** The BIP-48 path for our cosigning identity on a given seed. */
|
||||
export function bip48CosignPath(network: SupportedNetwork, accountIndex: number): string {
|
||||
// coin_type: 145' for BCH (BIP-44 / SLIP-0044). Script type 2' = multi-sig.
|
||||
// chipnet/testnet4 share coin_type with mainnet in this project's keystore
|
||||
// (see src/keystore/vault.ts — networks differ by address encoding, not
|
||||
// derivation path).
|
||||
void network;
|
||||
return `m/48'/145'/${accountIndex}'/2'/0/0`;
|
||||
}
|
||||
229
shells/app/test/multisig-cosign.test.ts
Normal file
229
shells/app/test/multisig-cosign.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Validate the Aegis cosign transport (src/multisig/cosign.ts) against the
|
||||
* published spec at bns://aegis.x/multisig/ — the test vector in §08 is the
|
||||
* acceptance bar. Interoperability with any other wallet speaking
|
||||
* aegis.cosign.v1 (Aegis itself, any third-party implementation) rides on
|
||||
* these exact bytes.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
PROTOCOL,
|
||||
MAX_MESSAGE_AGE_MS,
|
||||
type Descriptor,
|
||||
type Envelope,
|
||||
type Message,
|
||||
deriveChannelId,
|
||||
deriveChannelKey,
|
||||
seal,
|
||||
unseal,
|
||||
makeCosign,
|
||||
seedBytesFor,
|
||||
} from '../src/multisig/cosign';
|
||||
|
||||
// ── Spec §08: test vector ────────────────────────────────────────────────
|
||||
const VECTOR_DESCRIPTOR: Descriptor = {
|
||||
chain: 'bch',
|
||||
network: 'chipnet',
|
||||
m: 2,
|
||||
xpubs: [
|
||||
// Intentionally UNSORTED — the derivation must sort them before hashing.
|
||||
'xpub6DMExqxuPUeWryR35SXtvwyqAMkDKUUTmnSAaYkHmgJ2z6rtwMNUedsJ7BTtMX4q4PUVHY7MpKXoSvz8tvDnFk8v4LNnNmH9ErEjUdV4npp',
|
||||
'xpub6D1gTpfGtFkL8t2yKgoEKDFRwseCsgGqjSb119sR4wzsqtQU8pGqGGqu12dVNEYnH7Mv79CJYLg8biy3Jt7wKcFG83DYi6GhNQV6ifyLEvq',
|
||||
'xpub6CoQeu32piSNVz7NP67PUoo4hqQpKsi6opvhAWqfkyHoNG6FRfyCZ5f9AN1xEqbNo2ripAFd3j7yj1xJVvvGKVmQq9NNwc9DREuf9Gngius',
|
||||
],
|
||||
};
|
||||
const EXPECTED_CHANNEL_ID = '8cca5fb6eedbe5764a8de31e8ccc5298a0dadb81c7ee33b5a393fe48c803174c';
|
||||
const SPEC_ENVELOPE: Envelope = {
|
||||
p: 'aegis.cosign.v1',
|
||||
ch: '8cca5fb6eedbe5764a8de31e8ccc5298a0dadb81c7ee33b5a393fe48c803174c',
|
||||
iv: 'KioqKioqKioqKioq', // base64 of 12× 0x2a
|
||||
ct: '1xbh1I9lcw1cbhPCv1tXFgLvbEE/ci2Zdo8+Iga2xo0/i3/sbTXWTxKB0nEisLVvvclIzyeRtS3vmx3tbS8thDkMm5hKdZRGAHftogjVxbYpTjdFPDQ/lKLBnfRWRuG/lLj2T7nqAJ80+ck0AAvX7be3vm55uQ==',
|
||||
};
|
||||
const SPEC_PLAINTEXT: Message = {
|
||||
kind: 'request',
|
||||
at: 1_700_000_000_000,
|
||||
txId: 'abc',
|
||||
from: 'laptop',
|
||||
note: null,
|
||||
pst: { hello: 'world' },
|
||||
};
|
||||
const ALL_2A_IV = new Uint8Array(12).fill(0x2a);
|
||||
|
||||
describe('cosign — spec §02 derivation', () => {
|
||||
test('channel id matches §08 vector', async () => {
|
||||
expect(await deriveChannelId(VECTOR_DESCRIPTOR)).toBe(EXPECTED_CHANNEL_ID);
|
||||
});
|
||||
|
||||
test('xpub order does not matter — pre-sorted equivalent', async () => {
|
||||
const sorted: Descriptor = {
|
||||
...VECTOR_DESCRIPTOR,
|
||||
xpubs: [...VECTOR_DESCRIPTOR.xpubs].sort(),
|
||||
};
|
||||
expect(await deriveChannelId(sorted)).toBe(EXPECTED_CHANNEL_ID);
|
||||
});
|
||||
|
||||
test('seed bytes are canonical JSON with the exact field order', () => {
|
||||
const bytes = seedBytesFor(VECTOR_DESCRIPTOR);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
// Field order must be p, chain, network, m, xpubs — regex pins the sequence.
|
||||
expect(json).toMatch(/^\{"p":"aegis\.cosign\.v1","chain":"bch","network":"chipnet","m":2,"xpubs":\[/);
|
||||
// xpubs are sorted inside the JSON regardless of input order.
|
||||
const sortedQuoted = [...VECTOR_DESCRIPTOR.xpubs].sort().map((x) => `"${x}"`).join(',');
|
||||
expect(json).toContain('"xpubs":[' + sortedQuoted + ']');
|
||||
});
|
||||
|
||||
test('channel id and channel key are both deterministic', async () => {
|
||||
const [id1, id2] = await Promise.all([deriveChannelId(VECTOR_DESCRIPTOR), deriveChannelId(VECTOR_DESCRIPTOR)]);
|
||||
expect(id1).toBe(id2);
|
||||
const k1 = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const k2 = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
// CryptoKey objects are opaque — verify by encrypting the same thing twice
|
||||
// with a fixed IV and comparing the ciphertext.
|
||||
const e1 = await seal(k1, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT, ALL_2A_IV);
|
||||
const e2 = await seal(k2, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT, ALL_2A_IV);
|
||||
expect(e1.ct).toBe(e2.ct);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosign — spec §08 envelope', () => {
|
||||
// The spec vector's `at: 1700000000000` is from 2023-11-14. The 24-h replay
|
||||
// guard would (correctly) reject it in prod. Freeze time at vector+1s so the
|
||||
// round-trip test actually exercises decryption and the age guard both.
|
||||
beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date(SPEC_PLAINTEXT.at + 1000)); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
test('sealing the spec plaintext with the spec IV reproduces the spec ciphertext', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT, ALL_2A_IV);
|
||||
expect(env).toEqual(SPEC_ENVELOPE);
|
||||
});
|
||||
|
||||
test('unsealing the spec envelope reproduces the spec plaintext', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const msg = await unseal(key, EXPECTED_CHANNEL_ID, SPEC_ENVELOPE);
|
||||
expect(msg).toEqual(SPEC_PLAINTEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosign — spec §03 confidentiality + hardening', () => {
|
||||
test('fresh IV per seal — never twice the same ciphertext from the same plaintext', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const a = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT);
|
||||
const b = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT);
|
||||
expect(a.iv).not.toBe(b.iv);
|
||||
expect(a.ct).not.toBe(b.ct);
|
||||
});
|
||||
|
||||
test('wrong channel id in AAD → unseal returns null (tamper = decrypt fail = same error)', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT);
|
||||
const forged: Envelope = { ...env, ch: '00'.repeat(32) };
|
||||
expect(await unseal(key, '00'.repeat(32), forged)).toBeNull();
|
||||
});
|
||||
|
||||
test('tampered ciphertext → unseal returns null', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT);
|
||||
const bytes = Uint8Array.from(atob(env.ct), (c) => c.charCodeAt(0));
|
||||
bytes[0] = bytes[0]! ^ 0x01;
|
||||
const forged: Envelope = { ...env, ct: btoa(String.fromCharCode(...bytes)) };
|
||||
expect(await unseal(key, EXPECTED_CHANNEL_ID, forged)).toBeNull();
|
||||
});
|
||||
|
||||
test('wrong protocol string → unseal returns null without decrypting', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, SPEC_PLAINTEXT);
|
||||
const forged = { ...env, p: 'nope.v1' } as unknown as Envelope;
|
||||
expect(await unseal(key, EXPECTED_CHANNEL_ID, forged)).toBeNull();
|
||||
});
|
||||
|
||||
test('replay guard: messages older than 24 h are refused', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const stale: Message = { ...SPEC_PLAINTEXT, at: Date.now() - MAX_MESSAGE_AGE_MS - 1000 };
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, stale);
|
||||
expect(await unseal(key, EXPECTED_CHANNEL_ID, env)).toBeNull();
|
||||
});
|
||||
|
||||
test('fresh messages pass the age check', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const fresh: Message = { ...SPEC_PLAINTEXT, at: Date.now() - 1000 };
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, fresh);
|
||||
const out = await unseal(key, EXPECTED_CHANNEL_ID, env);
|
||||
expect(out?.kind).toBe('request');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosign — spec §04 payloads', () => {
|
||||
test('all three message kinds round-trip', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const kinds: Message[] = [
|
||||
{ kind: 'request', at: Date.now(), txId: 't1', from: 'laptop', note: 'rent', pst: { foo: 'bar' } },
|
||||
{ kind: 'signed', at: Date.now(), txId: 't2', from: 'phone', pst: { signed: true } },
|
||||
{ kind: 'declined', at: Date.now(), txId: 't3', from: 'phone', reason: 'not expecting this' },
|
||||
];
|
||||
for (const msg of kinds) {
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, msg);
|
||||
expect(await unseal(key, EXPECTED_CHANNEL_ID, env)).toEqual(msg);
|
||||
}
|
||||
});
|
||||
|
||||
test('from and note are capped at 200 chars (spec)', async () => {
|
||||
const key = await deriveChannelKey(VECTOR_DESCRIPTOR);
|
||||
const tooLong = 'x'.repeat(201);
|
||||
const bad: Message = { kind: 'request', at: Date.now(), txId: 't', from: tooLong, note: null, pst: {} };
|
||||
const env = await seal(key, EXPECTED_CHANNEL_ID, bad);
|
||||
expect(await unseal(key, EXPECTED_CHANNEL_ID, env)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosign — session (end-to-end through a mock transport)', () => {
|
||||
test('ask / reply / decline all reach the peer and only the peer', async () => {
|
||||
// Two in-memory transports sharing a bus — simulates two devices connected
|
||||
// to the same relay. Each only sees envelopes for its channel.
|
||||
const bus = new Map<string, Set<(e: Envelope) => void>>();
|
||||
const makeTransport = () => ({
|
||||
async publish(ch: string, env: Envelope) {
|
||||
for (const h of bus.get(ch) ?? []) h(env);
|
||||
},
|
||||
async subscribe(ch: string, h: (e: Envelope) => void) {
|
||||
if (!bus.has(ch)) bus.set(ch, new Set());
|
||||
bus.get(ch)!.add(h);
|
||||
return () => { bus.get(ch)?.delete(h); };
|
||||
},
|
||||
});
|
||||
|
||||
const inboxA: Message[] = [];
|
||||
const inboxB: Message[] = [];
|
||||
const cosign = makeCosign();
|
||||
const sessA = await cosign.session(VECTOR_DESCRIPTOR, makeTransport(), { onMessage: (m) => inboxA.push(m) });
|
||||
const sessB = await cosign.session(VECTOR_DESCRIPTOR, makeTransport(), { onMessage: (m) => inboxB.push(m) });
|
||||
|
||||
expect(sessA.channelId).toBe(EXPECTED_CHANNEL_ID);
|
||||
expect(sessB.channelId).toBe(sessA.channelId);
|
||||
|
||||
await sessA.ask({ pst: { x: 1 }, txId: 'tx1', from: 'laptop', note: 'rent' });
|
||||
await sessB.reply({ pst: { x: 1, sigs: ['laptop', 'phone'] }, txId: 'tx1', from: 'phone' });
|
||||
await sessB.decline({ txId: 'tx2', from: 'phone', reason: 'not expecting' });
|
||||
|
||||
// publish resolves before the subscribe handlers' async unseal (WebCrypto)
|
||||
// settles; wait for both inboxes to drain. 500 ms is comfortably over the
|
||||
// WebCrypto round-trip but well under the test suite's per-test budget.
|
||||
await vi.waitFor(() => {
|
||||
expect(inboxA.length).toBe(3);
|
||||
expect(inboxB.length).toBe(3);
|
||||
}, { timeout: 500 });
|
||||
|
||||
// Each device's own send echoes back through the bus too; both inboxes see
|
||||
// the three events. The spec's own §06 says the signing layer dedupes.
|
||||
expect(inboxA.map((m) => m.kind).sort()).toEqual(['declined', 'request', 'signed']);
|
||||
expect(inboxB.map((m) => m.kind).sort()).toEqual(['declined', 'request', 'signed']);
|
||||
|
||||
await sessA.close();
|
||||
await sessB.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosign — constants exported for interop', () => {
|
||||
test('PROTOCOL matches the spec string', () => expect(PROTOCOL).toBe('aegis.cosign.v1'));
|
||||
test('MAX_MESSAGE_AGE_MS matches 24 hours', () => expect(MAX_MESSAGE_AGE_MS).toBe(86_400_000));
|
||||
});
|
||||
|
|
@ -3081,3 +3081,97 @@ the follow-on session with the covenant author.
|
|||
backend: `Argus/src/lib/sia-upload.js`, `Argus/wallets.json` (the moderation-authority
|
||||
key). BNS gateway: `Argus/src/gateway/public-gateway.mjs`. Mockup preview: commit `d98f0a0`
|
||||
(`design/potidaea/app.html` glyph picker + icon uploader).
|
||||
|
||||
---
|
||||
|
||||
## ADR-0056 — Multi-sig wallet: m-of-n via BIP-48 xpubs, transport = Aegis cosign over Nostr
|
||||
|
||||
**Date:** 2026-10-22
|
||||
**Status:** **Partially shipped** — the transport layer (B) is implemented and
|
||||
tests pass against the Aegis spec's §08 test vector. The signing layer (A) and
|
||||
UI (C) are scaffolded with types only; the covenant, PST construction, and
|
||||
flows are the next sessions' work.
|
||||
|
||||
**Context.** Deviant's keystore (ADR-0083 model) ships `independent` + `hd-account`
|
||||
single-signer wallets. Adding m-of-n multi-sig has two orthogonal pieces: (A) the
|
||||
*signing* layer — redeem scripts, address derivation, partial transactions and
|
||||
signature aggregation — and (B) the *co-sign transport* — how a partially-signed
|
||||
transaction gets from the first device to the second. The Aegis project (sibling
|
||||
of Theseus and Hermes) published a self-contained open spec for B at
|
||||
`bns://aegis.x/multisig/`, with a test vector that any interoperable
|
||||
implementation can hit byte-for-byte. We adopt that spec unchanged so a Deviant
|
||||
wallet can co-sign with an Aegis wallet (and any other spec implementor) without
|
||||
either side knowing the other exists.
|
||||
|
||||
### Decision
|
||||
|
||||
**Layered split, pick each one on its own merits.**
|
||||
|
||||
#### (A) Signing layer — per-wallet script form, locked set of design choices
|
||||
|
||||
| Dimension | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Script form | **Per-wallet pick at creation:** `covenant` (default) · `native` (opt-in) | `covenant` = a `DeviantMultiSig.cash` CashScript — unlimited m-of-n, first-class CashToken support, aligns with the rest of Deviant. `native` = BCH's `OP_CHECKMULTISIG` under P2SH-20 — proven for a decade, smaller tx, 15-of-20 cap, limited CashToken story. Both paths visible in `src/multisig/types.ts::MultisigScriptForm`. |
|
||||
| CashTokens | **Supported** for both forms | For `covenant`: baked into the covenant. For `native`: via P2SH32 variant. Fiat/MBA holdings are the whole point of a Deviant multi-sig; non-negotiable. |
|
||||
| PST format on the wire | **Deviant JSON PST** aligned with Aegis spec example shape (`{v, chain, network, channelId, unsignedTx, signatures, hint?}`) | Opaque to the transport, interoperable with any wallet speaking the same shape. Internally the signing layer builds on `@bitauth/libauth` partial-signing primitives; `toJson` / `fromJson` adapters at the session boundary convert. |
|
||||
| Keystore model | **New `multisig` kind in MultiKeyStore** beside `independent` + `hd-account` | One store, one unlock, one shared PIN — same ergonomics as the rest. Record carries `composition: {m, n, xpubs[], mineIndex, scriptForm}` + `seedId` + `accountIndex`. |
|
||||
| HD derivation path | **BIP-48** — `m/48'/145'/<accountIndex>'/2'/0/0` | Industry standard for multi-sig; `2'` = multi-sig script type. Keeps our cosigning identity on a seed's dedicated branch, separate from `hd-account` (`m/44'/145'/N'/0/0`). |
|
||||
|
||||
#### (B) Transport — Aegis `aegis.cosign.v1` unchanged
|
||||
|
||||
- `src/multisig/cosign.ts` — HKDF-SHA256 channel derivation over canonical
|
||||
descriptor JSON (`{"p","chain","network","m","xpubs":[…sorted…]}`); AES-256-GCM
|
||||
envelope with channel id as AAD; three message kinds (`request`, `signed`,
|
||||
`declined`); 24-hour replay guard.
|
||||
- `src/multisig/cosign-relay.ts` — Nostr NIP-59 gift-wrap (kind 1059) transport.
|
||||
Fresh per-session signing key so relays cannot link conversations. Single-filter
|
||||
`subscribeMany` (array form silently matches nothing — spec warning in §05).
|
||||
- **Test vector match:** channel id `8cca5fb6…`, spec envelope round-trip,
|
||||
tamper + replay rejection — 17/17 tests green in `test/multisig-cosign.test.ts`.
|
||||
- MPL-2.0, matching the Aegis reference code.
|
||||
|
||||
### Open questions (next session owns these)
|
||||
|
||||
1. **DeviantMultiSig.cash covenant** — needs CashScript design + chipnet validation
|
||||
(both the m-of-n signature verification and the CashToken pass-through branches).
|
||||
Aegis itself ships P2SH-20 for BCH (per their `lib/chain-bch.js`) so there is no
|
||||
covenant reference to port — we draft from scratch.
|
||||
2. **PST signing** — libauth's partial-transaction API is low-level; the adapter to
|
||||
our JSON format needs careful review (signature re-ordering by xpub index,
|
||||
SIGHASH flag handling for CashToken outputs, SIGHASH_SINGLE edge cases if we
|
||||
ever expose it).
|
||||
3. **Mine-index authentication** — nothing in the cosign layer tells us a `signed`
|
||||
message came from the xpub whose index it claims. The signing layer verifies
|
||||
the signature against the input's sighash + the claimed xpub's pubkey; if that
|
||||
fails we drop the message. Spec is explicit (§06): the signing layer is the
|
||||
authorisation boundary.
|
||||
4. **UI** — Create flow (paste co-signers' xpubs + QR share own) · Inbox for pending
|
||||
`request` + `signed` messages · Approve/decline with tx preview rendered against
|
||||
our own key derivation (never trust `hint`).
|
||||
5. **Keystore migration** — add `kind: 'multisig'` to `WalletRecord`'s discriminated
|
||||
union; `migrate()` leaves existing records untouched; `unlockAll` opens the
|
||||
multi-sig entry's cosigning branch (not a spending key — the wallet is scripted,
|
||||
not key-locked).
|
||||
|
||||
### Consequences
|
||||
|
||||
- Any wallet implementing `aegis.cosign.v1` can co-sign with a Deviant multi-sig.
|
||||
Opens interop with Aegis (same org), and with any third-party wallet that reads
|
||||
the spec.
|
||||
- The 24-hour replay guard means a stale request simply isn't actionable — the
|
||||
user has to re-propose. No long-horizon pending approvals.
|
||||
- Nostr relays see "a channel exists" + rough traffic size. Nothing more. The
|
||||
co-signer set is as private as the xpubs themselves (which anyone who knows
|
||||
them could already watch on chain).
|
||||
- Per-session fresh Nostr signing keys mean we cannot be rate-limited or
|
||||
reputation-tracked across requests. The relay sees activity, not identity.
|
||||
|
||||
**Cross-references.**
|
||||
- Aegis spec: `bns://aegis.x/multisig/` (served at `navigate.st/bns/aegis.x/multisig/`)
|
||||
- Aegis source: `code.silentmode.st/silentmode/aegis` (sibling repo; wallet shell at `lib/chain-bch.js`)
|
||||
- Deviant implementation: `shells/app/src/multisig/{cosign,cosign-relay,types}.ts`
|
||||
- Deviant tests: `shells/app/test/multisig-cosign.test.ts` (17/17 pass against §08 vector)
|
||||
- Keystore host: `shells/app/src/keystore/MultiKeyStore.ts` (ADR-0083 model;
|
||||
`multisig` kind to be added next session)
|
||||
- Covenant: `contracts/DeviantMultiSig.cash` (to be written)
|
||||
- Memory: [[multi-account-keystore]], [[chipnet-shared-loader]], [[cashscript-covenant-findings]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue