DACTIE lets organizations share cyber threat intelligence (CTI) without revealing that they were the victim, and still keeps senders accountable. It is a permissioned peer-to-peer network without a blockchain: a sender stays anonymous unless a threshold of archives jointly opens its group signature.
This repository contains the Rust prototype, the experiments, and the first public implementation of the threshold dynamic group signature scheme of Camenisch et al. (2020) that DACTIE builds on.
TODO (Vero): link to the paper and add its BibTeX under Citation -> wait for WAITI ACSAC's 2026 proceedings.
| Role | Binary | What it does |
|---|---|---|
| Authorization Authority (AA) | dactie-authority |
Admits members and archives and fixes the opening threshold t. Can ask for an opening, but cannot open a signature alone. |
| Archive (ARC) | dactie-archive |
Stores every message in its own PostgreSQL database and holds one share of every member key. Its operator approves or denies each opening request. |
| Member | – | An organization admitted by the AA. It runs up to k sharing peers per epoch (default 5). |
| Sharing peer | dactie-peer |
Publishes and receives CTI. The AA cannot link a sharing peer's PeerID to its member. |
- Sender anonymity: every message carries a group signature that proves "an admitted member sent this" but not which one. No message carries the sender's PeerID.
- Accountability:
tofnarchives together can open a signature and name the member. Fewer archives cannot, and neither can the AA alone. - Confidentiality and integrity: messages are encrypted with MLS, to all members or to a private group, and signed.
- Receiver anonymity (partitioned channels): a topic is not a GossipSub topic of its own. Each topic maps to one of 10 channels,
hash(topic) mod 10(NUM_CHANNELSincrates/dactie-utils/src/mls_wrapper.rs). Only the channel travels in clear; the topic is inside the ciphertext. A peer receives every message on its channel and drops the topics it did not subscribe to, so neighbours see which channels a peer joined, but not which topics it reads. Fewer channels hide interests better, at the cost of more traffic per peer. - Network-level anonymity: a stem phase (Dandelion++) hides which peer published a message first. A coalition of
ccolluding peers among|N_c|names the sender in aboutc/|N_c|of the cases. - Not covered: CTI content that itself identifies the victim, global passive adversaries, and
tcolluding archives.
- The sender
Sencrypts and signs its message, then passes it unchanged along a random stem of sharing peers, one hop at a time. - The peer at the end of the stem, the diffuser
D, publishes it into GossipSub. Every peer checks the group signature before forwarding, and drops invalid messages. - Archives subscribe to all channels and store every message.
- Nodes that are not admitted (the outsider) receive nothing.
A message is public or sent to a private group, and anonymous or attributed:
| Peer command | Who can read it | Sender shown to readers |
|---|---|---|
broad <topic> <msg> |
all members (common group) | Sender: anonymous |
attr_broad <topic> <msg> |
all members | Attributed to member: <MemberID> |
enc_broad <group_number> <msg> |
members of one private group | Sender: anonymous |
attr_enc_broad <group_number> <msg> |
members of one private group | Attributed to member: <MemberID> |
Attributed messages skip the stem, since they name their member anyway. Members use them, e.g., to build reputation.
You need Rust via rustup (not your distribution's package), Docker (for two PostgreSQL databases) and tmux.
git clone https://github.com/VeroSec/DACTIE.git && cd DACTIE
cargo build --release
scripts/e2e_local.shThe script starts an AA, two archives and several sharing peers, and checks every step. Each passed check prints OK …, and the first failure prints FAIL: … and stops. It checks:
- a member joins from a throwaway identity, so the AA never sees its own PeerID;
- a second sharing peer of the same member registers;
- anonymous broadcasts arrive and travel along the stem;
- an unregistered observer receives nothing;
- both archives approve an opening, and it names the right member.
Logs go to target/e2e/. The nodes keep running for inspection; stop them with:
tmux kill-session -t camera; docker rm -f camera-pg1 camera-pg2Three longer runs:
scripts/e2e_expiry.sh(about 3 minutes): sharing peers that do not renew are removed at the end of their epoch.scripts/e2e_nested.sh(about 5 minutes): private groups, and removing an expired peer from them.scripts/e2e_threshold.sh(about 4 minutes): three AAs, any two of which issue a member key (PROTOCOL.md, section 8).
You start one AA, two archives and two sharing peers, each in its own terminal. Build first with cargo build --release; all commands below run from the repository root. Lines starting with > are typed into a node's console, not the shell.
export PGPASSWORD='change-me' # choose your own database password
for i in 1 2; do
docker run -d --rm --name dactie-pg$i -e POSTGRES_USER=archive -e POSTGRES_PASSWORD="$PGPASSWORD" \
-e POSTGRES_DB=archivedb -v "$PWD/init.sql:/docker-entrypoint-initdb.d/init.sql:ro" \
-p 127.0.0.1:$((5431 + i)):5432 postgres:16
done
mkdir -p km/aa km/arc1 km/arc2 km/peer1 km/peer2Archive 1 uses port 5432 and archive 2 port 5433. The archives read the password from PGPASSWORD, so it never appears in a URL or the process list; export it in their terminals too (step 3). If a local PostgreSQL already uses 5432, change both ports here and in step 3.
RUST_LOG=info target/release/dactie-authority -K km/aaIt listens on port 10001. Copy its PeerID from the log line Local PeerId: 12D3KooW… and set it in every other terminal:
export AA=12D3KooW… # the AA's PeerIDRUST_LOG=info target/release/dactie-archive -K km/arc1 -i archive1 -a $AA -B /ip4/127.0.0.1/tcp/10001 \
-u postgres://archive@127.0.0.1:5432/archivedbRUST_LOG=info target/release/dactie-archive -K km/arc2 -i archive2 -a $AA -B /ip4/127.0.0.1/tcp/10001 \
-u postgres://archive@127.0.0.1:5433/archivedbOnce both have registered, close the init phase in the AA's console:
> end_init_archive
The AA logs Enough Archives registered, ending init phase (opening threshold 2 of 2). This fixes the threshold for good, since every member splits its key for it. The default is a strict majority of the archives; start the AA with --threshold <t> to choose another. Do not restart the AA between registering the archives and end_init_archive.
RUST_LOG=info target/release/dactie-peer -K km/peer1 -b $AA -B /ip4/127.0.0.1/tcp/10001> register_identity <AA PeerID> org1
The peer joins from a throwaway identity (Joining from throwaway identity …), and then registers its own PeerID as a sharing peer (Registering with slot 1 of epoch …). A node that is not registered gets no messages, and other nodes close its connection after 60 s, so register within a minute of starting; otherwise the node reconnects when you send the request.
Start peer2 the same way with -K km/peer2. Then choose one of two options:
-
A second member:
> register_identity <AA PeerID> org2 -
A second sharing peer of org1, which the AA cannot link to peer1:
> get_opener_keys <AA PeerID> > peer_from_sig <AA PeerID> km/peer1/key_material.json
On both peers:
> sub incidents
On peer2:
> broad incidents Indicator: 203.0.113.7 contacted our VPN gateway
peer1 prints the message, Sender: anonymous, and the message's signature as hex. You need the signature for an opening (step 7).
Get peer2's PeerID with > peer_id, then on peer1:
> req_kps <peer2 PeerID>
> create_group <peer2 PeerID>
> ls_groups
> enc_broad <group_number> only for this group
In the AA's console:
> req_open <signature hex>
Every archive prints Opening request <id> from the authority is pending. Each archive operator decides in that archive's console:
> approve <id>
Or > deny <id>, or > pending to list open requests. Pending requests are stored in the archive's database and survive a restart. Once t archives have approved, run in the AA's console:
> open <signature hex>
It prints the member's ID and name, and every PeerID of that member.
docker rm -f dactie-pg1 dactie-pg2
rm -rf km # key material of this test networkAll options of the three binaries, with defaults, are in docs/PROTOCOL.md, "Running the nodes". A peer prints its command list on any unknown command.
Sharing peer (dactie-peer)
| Command | Effect |
|---|---|
register_identity <AA PeerID> <name> |
Join as a new member |
get_opener_keys <AA PeerID> |
Fetch the archives' keys, the epoch length and k (before peer_from_sig) |
peer_from_sig <AA PeerID> <key_material.json> |
Register this node as another sharing peer of an existing member, using one of its k slots |
renew <AA PeerID> <key_material.json> |
Keep this sharing peer registered in the next epoch; without it, the AA removes the peer when its epoch ends |
sub <topic> / unsub <topic> |
Subscribe to or unsubscribe from a topic |
broad, attr_broad, enc_broad, attr_enc_broad |
Send a message; see How a message travels |
req_kps <PeerID>… |
Request MLS key packages from these peers (before adding them to a group) |
create_group <PeerID>… |
Create a private group with these peers |
ls_groups |
List private groups with their group_number |
add_group_member <group_number> <PeerID> / remove_group_member <group_number> <PeerID> |
Change a group's members (owner only; the owner cannot be removed) |
delete_group_for_all <group_number> |
Delete a group for all its members (owner only) |
update_key_material <group_number> |
Renew your own key material in a group |
update_groups <archive PeerID> |
Fetch missed group updates from an archive |
peer_id |
Print this node's PeerID |
Authority (dactie-authority): end_init_archive, req_open <signature hex>, open <signature hex>.
Archive (dactie-archive): pending, approve <id>, deny <id>. With Docker, attach with docker attach <container> and detach with Ctrl+P, Ctrl+Q, which leaves the archive running.
- Trusted archives: start every peer with
--archive /ip4/<ip>/tcp/<port>/p2p/<archive PeerID>for each archive, published out of band. The peer then checks the archive keys the AA hands out and refuses to join on a mismatch. Without it, the peer warns and trusts the AA's keys. - Stem phase:
--stem-fluff-probability(default 0.2, Monero's value) is the chance that a peer is a diffuser in an epoch.0turns the stem off. - Sharing peers per member:
--peers-per-member <k>(default 5) and--epoch-secs(default 30 days) on the AA. - Connections: each peer keeps between
--conn-low(50) and--conn-high(100) connections. - Tests only:
--auto-approve-openmakes an archive approve every opening without its operator, so the AA could open any signature on its own.
docker-compose.yml describes the paper's evaluation deployment on four hosts, with one compose profile per host (node116–node119) and fixed IP addresses. ./build.sh builds the images, and docs/RUNBOOK.md walks through the deployment and its checks. To try DACTIE on one machine, use the walkthrough instead.
| Folder | Contents |
|---|---|
crates/dactie-authority, crates/dactie-archive, crates/dactie-peer |
The three node types (authority, archive, peer) |
crates/dactie-utils |
Code shared by all nodes: networking, admission, MLS wrapper, message types (modules) |
crates/thressig |
Threshold dynamic group signatures (Camenisch et al. 2020), single-issuer variant |
experiments/anonymity |
Simulation of the coalition first-spy attack, with and without the stem phase |
experiments/eclipse |
Simulation of eclipse attacks without flood publishing |
experiments/benchmark |
Microbenchmarks of thressig |
experiments/fabric |
Hyperledger Fabric 2.5 on the same testbed, for the comparison with DACTIE's rate sweep (README) |
scripts |
End-to-end tests, evaluation runs and their summaries |
results |
The data behind every result of the paper (README) |
workloads |
MISP and STIX payloads of the latency runs |
docs |
PROTOCOL.md (every message, check and stored value), EVALUATION.md (how each result was produced), RUNBOOK.md (the four-host testbed), DANDELION.md (stem phase design), figures |
docs/EVALUATION.md lists for every result how it was produced, the script that runs it again and where its data is. The data itself is in results/.
| Result | Where |
|---|---|
| Latency, soak test and archive queries on four hosts | docs/EVALUATION.md, testbed in docs/RUNBOOK.md |
| Cost of signing, verifying and opening | experiments/benchmark/README.md |
| Anonymity against colluding peers | docs/EVALUATION.md |
| Eclipse attacks | docs/EVALUATION.md |
| Comparison with Hyperledger Fabric on the same hosts | docs/EVALUATION.md |
- It is a research prototype and has not been audited.
- The AA accepts any unused name; a deployment vets each organization out of band.
- Private groups use P2Panda's workaround for the missing decentralized MLS: only the group owner commits changes, and the owner cannot be removed.
TODO (Vero): BibTeX of the paper.
DACTIE is open for contributions: bug reports, fixes, documentation, experiments and research ideas are all welcome.
- Understand the protocol first: docs/PROTOCOL.md lists every message, check and stored value. Change it together with the code it describes.
- Test your change:
scripts/e2e_local.shmust print onlyOKlines. If you touch groups, peer expiry or the stem, also runscripts/e2e_nested.shorscripts/e2e_expiry.sh. - Discuss larger changes first: open an issue on GitHub, then a pull request against
main. Small fixes can go straight to a pull request.
Open problems from the paper and the prototype, a good place to start:
- Sanctions after an opening: removing or blocking a member once its signature has been opened.
- Tor for the first stem hop: the first stem hop still sees the sender's PeerID; if it colludes, it names the sender.
- Decentralized MLS: removes the group-owner workaround in private groups.
- Admission by several authorities: today a single AA admits members.
- Content anonymity: CTI whose content identifies the victim.
- AI-Agent contribution: Add mechanism for AI-Agent-authorization and let them participate to filter traffic and write CTI for participating companies.
By contributing, you agree that your contribution is licensed under the Apache License 2.0, like the rest of the repository.
Apache License 2.0, see LICENSE.