Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions ARCs/arc-0091.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
---
arc: 91
title: Encrypted Multisig (ARC-55 Extension)

Check failure on line 3 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

preamble header `title` should not contain `ARC-`

error[preamble-re-title-arc]: preamble header `title` should not contain `ARC-` --> ARCs/arc-0091.md:3:7 | 3 | title: Encrypted Multisig (ARC-55 Extension) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ prohibited pattern was matched | = info: the pattern in question: `(?i)ARC[\s-]*[0-9]+`
description: This ARC defines an extension to ARC-55, adding per-signer encrypted transaction storage using ECDH key exchange and ChaCha20-Poly1305 encryption.

Check failure on line 4 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

proposals must be referenced with the form `ARC-N` (not `arc-N`)

error[preamble-re-description-arc]: proposals must be referenced with the form `ARC-N` (not `arc-N`) --> ARCs/arc-0091.md:4:13 | 4 | description: This ARC defines an extension to ARC-55, adding per-signer encrypted transaction storage using ECDH key exchange and ChaCha20-Poly1305 encryption. | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ prohibited pattern was matched | = info: the pattern in question: `(?i)ARC[\s-]*[0-9]+`

Check failure on line 4 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

preamble header `description` value is too long (max 140)

error[preamble-len-description]: preamble header `description` value is too long (max 140) --> ARCs/arc-0091.md:4:13 | 4 | description: This ARC defines an extension to ARC-55, adding per-signer encrypted transaction storage using ECDH key exchange and ChaCha20-Poly1305 encryption. | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ too long |
author: Steve Ferrigno <steve.ferrigno@algorand.foundation>, Bruno Martins <bruno.martins@algorand.foundation>

Check failure on line 5 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

preamble header `author` must contain at least one GitHub username

error[preamble-author]: preamble header `author` must contain at least one GitHub username --> ARCs/arc-0091.md | 5 | author: Steve Ferrigno <steve.ferrigno@algorand.foundation>, Bruno Martins <bruno.martins@algorand.foundation> |
discussions-to: https://github.com/algorandfoundation/ARCs/discussions/<tbd>
status: Draft
type: Standards Track
category: Interface
sub-category: Wallet
created: 2026-02-11
requires: 55
---

## Abstract

This ARC extends [ARC-55](./arc-0055.md) by adding per-signer encrypted transaction storage. The only change from ARC-55 is the addition of a `signerIndex` parameter that allows each signer to store and retrieve their own encrypted version of each transaction. Encryption is performed off-chain using ECDH between a designated encryptor and each signer, with the resulting shared secret hashed via Blake2b and used as the key for ChaCha20-Poly1305 stream encryption.

## Motivation

ARC-55 stores transactions in plaintext, making them visible on-chain before execution. By encrypting each transaction specifically for each signer using their public key, transaction contents remain confidential among authorized parties until ready for execution.

## Specification

### ABI Changes from ARC-55

The ARC-55 ABI is modified with a `signerIndex` parameter and optional designated encryptor:

**Modified Methods:**

| Method | ARC-55 Signature | ARC-91 Signature |
|--------|----------------|------------------|

Check failure on line 32 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

the first match of the given pattern must be a link

error[markdown-link-first]: the first match of the given pattern must be a link --> ARCs/arc-0091.md | 32 | |--------|----------------|------------------| | = info: the pattern in question: `(?i)ARC-[0-9]+`
| `arc55_getTransaction` | `(uint64,uint8)byte[]` | `(uint64,uint8,uint8)byte[]` |
| `arc55_addTransaction` | `(pay,uint64,uint8,byte[])void` | `(pay,uint64,uint8,uint8,byte[])void` |
| `arc55_removeTransaction` | `(uint64,uint8)void` | `(uint64,uint8,uint8)void` |
| `arc55_setup` | `(uint8,address[])void` | `(uint8,address[],address)void` |

**New Read-Only Method:**

| Method | Signature | Description |
|--------|----------|-------------|
| `arc55_getEncryptor` | `()address` | Returns the designated encryptor address |

The `signerIndex` parameter identifies which signer's encrypted version to store or retrieve, allowing each signer to have their own encrypted copy of the same transaction.

### Encryption Process

Encryption and decryption occur entirely off-chain. The contract only stores and retrieves opaque byte arrays.

#### Key Derivation (ECDH + Blake2b)

For each signer in the multisig, the designated encryptor derives an encryption key as follows:

1. **ECDH Exchange**: The encryptor performs ECDH using:
- Their own private key: `encryptorPriv`
- The signer's public key: `signerPub`

This produces a shared secret point on the curve.

2. **Serialization**: The shared point is serialized to 32 bytes using Ed25519 point compression (only the x-coordinate).

3. **Hashing with Blake2b**: The 32-byte point is hashed using **Blake2b-256** to produce a 256-bit encryption key:
```
encryptionKey = Blake2b-256(sharedPoint)
```

#### Encryption (ChaCha20-Poly1305)

Each transaction is encrypted using **ChaCha20-Poly1305**:

- **Key**: The 256-bit output from Blake2b-256 above
- **Nonce**: A 96-bit (12-byte) nonce, which **MUST** be unique per encryption operation. The nonce **SHOULD** be derived deterministically from the transaction group nonce and signer index, or generated randomly and prepended to the ciphertext. - **Plaintext**: The transaction bytes (msgpack-encoded unsigned transaction)
- **Ciphertext format**: `[nonce (12 bytes)] || [encrypted data] || [tag (16 bytes)]`

- `note on using stream ciphers`: **Re-using nonces in stream cipher modes is catastrophic for security as encrypted data can be collected and XOR techniques can be used to recover plaintext.** (See [Stream Cipher Key & Nonce Reuse](https://hacker101.linuxsec.org/vulnerabilities/stream_reuse))

Check failure on line 75 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

non-relative link or image

error[markdown-rel-links]: non-relative link or image --> ARCs/arc-0091.md | 75 | - `note on using stream ciphers`: **Re-using nonces in stream cipher modes is catastrophic for security as encrypted data can be collected and XOR techniques can be used to recover plaintext.** (See [Stream Cipher Key & Nonce Reuse](https://hacker101.linuxsec.org/vulnerabilities/stream_reuse)) |

The resulting encrypted transaction is what gets passed to `arc55_addTransaction` and stored in the contract.

#### Decryption Process

When a signer retrieves their encrypted transaction:

1. Retrieve ciphertext from contract using their `signerIndex`
2. Extract nonce from first 12 bytes
3. Query the contract for the designated encryptor address (via `arc55_getEncryptor`)
4. Perform ECDH using:
- Their own private key: `signerPriv`
- Encryptor's public key: `encryptorPub` (or `adminPub` if no encryptor set)
5. Serialize shared point and hash with Blake2b-256 to derive the same key
6. Decrypt using ChaCha20-Poly1305 with the extracted nonce
7. Verify the authentication tag to detect tampering

### Storage Changes

The only change to storage is the box key format for transaction boxes:

| ARC | Box Key Format | Size | Description |
|-----|---------------|------|-------------|
| ARC-55 | `{nonce}:{index}` | 9 bytes (uint64 + uint8) | Single version per transaction |
| ARC-91 | `{nonce}:{index}:{signerIndex}` | 10 bytes (uint64 + uint8 + uint8) | Per-signer encrypted version |

Check failure on line 100 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

the first match of the given pattern must be a link

error[markdown-link-first]: the first match of the given pattern must be a link --> ARCs/arc-0091.md | 100 | | ARC-91 | `{nonce}:{index}:{signerIndex}` | 10 bytes (uint64 + uint8 + uint8) | Per-signer encrypted version | | = info: the pattern in question: `(?i)ARC-[0-9]+`

**Example for a transaction group with 3 transactions and 3 signers:**

```
// Transaction 0
"txn:1:0:0" → encrypted_version_for_signer_0 // 10-byte key
"txn:1:0:1" → encrypted_version_for_signer_1
"txn:1:0:2" → encrypted_version_for_signer_2

// Transaction 1
"txn:1:1:0" → encrypted_version_for_signer_0
"txn:1:1:1" → encrypted_version_for_signer_1
"txn:1:1:2" → encrypted_version_for_signer_2

// etc.
```

### Global State

The contract adds one new global state variable:

| State Variable | Type | Description |
|---------------|------|-------------|
| `arc55_encryptor` | `address` | Designated encryptor address. All ECDH key agreements use this address. |

### Minimum Balance Requirement

The MBR for storing transactions increases by 400 microALGO per transaction due to the additional byte in the box key:

```
ARC-55: MBR = 2500 + 400 × (9 + transactionSize)
ARC-91: MBR = 2500 + 400 × (10 + transactionSize)
```

For N signers, storage costs are multiplied by N since each signer stores their own encrypted copy.

## Usage Flow

Check failure on line 137 in ARCs/arc-0091.md

View workflow job for this annotation

GitHub Actions / ARC Walidator

body has extra section(s)

error[markdown-order-section]: body has extra section(s) --> ARCs/arc-0091.md | 137 | ## Usage Flow |

1. **Admin deploys and configures the contract**: Calls `arc55_setup(threshold, addresses, encryptor)` with the designated encryptor address. The encryptor must be a valid (non-zero) address.

2. **Encryptor creates transaction group**: Calls `arc55_newTransactionGroup()` (unchanged from ARC-55)

3. **Encryptor encrypts and stores for each signer**:
```typescript
// Get the designated encryptor address from the contract
const encryptor = await contract.arc55_getEncryptor()

const txnBytes = transaction.toByte()

for (let i = 0; i < signers.length; i++) {
// Derive key via ECDH + Blake2b using encryptor's key
const sharedPoint = ed255dh(encryptor, signers[i].pubkey)
const key = blake2b(sharedPoint)

// Encrypt with ChaCha20-Poly1305
const nonce = deriveNonce(groupNonce, i)
const encrypted = chacha20poly1305.encrypt(txnBytes, key, nonce)
const payload = nonce.concat(encrypted).concat(tag)

// Store with signer index
await contract.arc55_addTransaction(costs, groupNonce, txnIndex, i, payload)
}
```

5. **Each signer retrieves and decrypts**:
```typescript
// Signer knows their index (e.g., signer 1)
const encrypted = await contract.arc55_getTransaction(groupNonce, txnIndex, 1)

// Get the designated encryptor address from the contract
const encryptor = await contract.arc55_getEncryptor()

// Derive same key via ECDH + Blake2b
const sharedPoint = ed255dh(signerPriv, encryptor)
const key = blake2b(sharedPoint)

// Decrypt
const txnBytes = chacha20poly1305.decrypt(encrypted, key)
```

6. **Sign and submit**: Signers use standard ARC-55 `arc55_setSignatures()` (unchanged)

## Security Considerations

1. **Off-chain encryption**: The smart contract never sees plaintext or performs cryptographic operations. All encryption/decryption happens client-side.

2. **ECDH security**: The shared secret is ephemeral and derived per encryption operation. Even if one transaction's key is compromised, other transactions remain secure.

3. **Blake2b hashing**: Prevents direct use of raw ECDH output, providing domain separation and ensures uniform key distribution.

4. **ChaCha20-Poly1305**: Provides authenticated encryption. The Poly1305 tag ensures ciphertext integrity and prevents tampering.

5. **Nonce uniqueness**: Critical for ChaCha20 security. Nonces **MUST** never be reused with the same key.

6. **Public metadata**: While transaction contents are encrypted, the following remain public:
- Transaction group nonce
- Number of transactions in group
- Number of signers
- Transaction sizes (revealed by box storage)
- Which signer index has signed (signature boxes)
- Designated encryptor address




10 changes: 10 additions & 0 deletions assets/arc-0091/.algokit.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[algokit]
min_version = "v1.12.1"

[project]
type = 'workspace'
projects_root_path = 'projects'

[generate.devcontainer]
description = "Generate a default 'devcontainer.json' configuration that pre-installs algokit and launches Algorand sandbox as part of codespace container provisioning."
path = ".algokit/generators/create-devcontainer"
5 changes: 5 additions & 0 deletions assets/arc-0091/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
debug-traces
coverage
.algokit
smart_contracts/artifacts
27 changes: 27 additions & 0 deletions assets/arc-0091/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# multisig

This project has been generated using AlgoKit. See below for default getting started instructions.

# Setup

### Pre-requisites

- [Nodejs 22](https://nodejs.org/en/download) or later
- [AlgoKit CLI 2.5](https://github.com/algorandfoundation/algokit-cli?tab=readme-ov-file#install) or later
- [Docker](https://www.docker.com/) (only required for LocalNet)
- [Puya Compiler 4.4.4](https://pypi.org/project/puyapy/) or later


- run `algokit localnet start`

### Compile contract

```sh
npm run build
```

### Run tests

```sh
npm test
```
18 changes: 18 additions & 0 deletions assets/arc-0091/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import globals from 'globals'

export default tseslint.config(
eslint.configs.recommended,
tseslint.configs.recommended,
{
languageOptions: {
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/explicit-member-accessibility': 'warn',
},
},
);
Loading
Loading