Skip to content

Commit 0b80dc7

Browse files
committed
Add CA+witness test-instance setup
- cactus-keygen -pub prints a cosigner's public key (the PEM form the mirror/quorum config fields consume), so a witness config can be filled in without starting the server. - config-witness-example.json: a single-process CA plus one in-process witness cosigner, where the CA collects the witness cosignature over loopback so every standalone cert carries CA + witness cosignatures. - docs/test-instance.md: setup, issuance (standalone + landmark-relative), and how to verify against the log; notes that a self-hosted witness is for exercising the wire formats, not real transparency.
1 parent d0f9a19 commit 0b80dc7

4 files changed

Lines changed: 257 additions & 7 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ cactus/
380380
├── cmd/
381381
│ ├── cactus/ main server binary (CA / mirror / both)
382382
│ ├── cactus-cli/ debugging client (tree show, entry, cert verify, prove)
383-
│ └── cactus-keygen/ fresh seed generator
383+
│ └── cactus-keygen/ cosigner seed generator (-pub prints the public key)
384384
├── acme/ RFC 8555 ACME server with §9 extensions
385385
├── ca/ Issuer (CSR → X.509 cert via id-alg-mtcProof)
386386
├── cert/ TBSCertificateLogEntry, MTCProof, MTCSubtreeSignatureInput,
@@ -394,7 +394,7 @@ cactus/
394394
├── tlogx/ §4 subtree primitives extending x/mod/sumdb/tlog
395395
├── metrics/ Prometheus instruments
396396
├── config/ JSON config loader
397-
├── docs/ threat-model, disk-layout
397+
├── docs/ threat-model, disk-layout, test-instance (CA + witness)
398398
└── integration/ end-to-end tests
399399
```
400400

cmd/cactus-keygen/main.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
// cactus-keygen writes a fresh 32-byte cosigner seed file. The seed is
2-
// then used by the cactus server to derive its CA cosigner private key
3-
// via HKDF (see signer/signer.go).
1+
// cactus-keygen writes a fresh 32-byte cosigner seed file, or (with
2+
// -pub) prints the cosigner public key of an existing seed as a PEM
3+
// block. The seed is used by the cactus server to derive its cosigner
4+
// private key via HKDF (see signer/signer.go); the public key is what
5+
// other components configure to verify that cosigner — a mirror's
6+
// upstream.ca_cosigner_key_pem or a CA quorum mirror's public_key_pem.
7+
// Those config fields take a PEM block whose body is the raw public key,
8+
// which is exactly what -pub emits.
49
package main
510

611
import (
12+
"encoding/pem"
713
"flag"
814
"fmt"
915
"os"
@@ -13,10 +19,19 @@ import (
1319
)
1420

1521
func main() {
16-
out := flag.String("o", "ca-cosigner.seed", "output seed file path")
17-
force := flag.Bool("f", false, "overwrite existing file")
22+
out := flag.String("o", "ca-cosigner.seed", "seed file path (written when generating, read when -pub)")
23+
force := flag.Bool("f", false, "overwrite an existing seed file when generating")
24+
pub := flag.Bool("pub", false, "print the PEM SubjectPublicKeyInfo for the seed at -o instead of generating one")
25+
alg := flag.String("alg", "mldsa-44", "cosigner algorithm (used with -pub)")
1826
flag.Parse()
1927

28+
if *pub {
29+
if err := printPublicKey(*out, *alg); err != nil {
30+
die("%v", err)
31+
}
32+
return
33+
}
34+
2035
dir := filepath.Dir(*out)
2136
if dir != "" && dir != "." {
2237
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -33,6 +48,25 @@ func main() {
3348
fmt.Fprintf(os.Stderr, "wrote %s (%d bytes)\n", *out, signer.SeedSize)
3449
}
3550

51+
// printPublicKey derives the cosigner key from the seed at seedPath and
52+
// writes the raw public key as a PEM "PUBLIC KEY" block to stdout — the
53+
// form the mirror/quorum config fields consume.
54+
func printPublicKey(seedPath, algName string) error {
55+
alg, err := signer.ParseAlgorithm(algName)
56+
if err != nil {
57+
return err
58+
}
59+
seed, err := signer.LoadSeed(seedPath)
60+
if err != nil {
61+
return err
62+
}
63+
s, err := signer.FromSeed(alg, seed)
64+
if err != nil {
65+
return err
66+
}
67+
return pem.Encode(os.Stdout, &pem.Block{Type: "PUBLIC KEY", Bytes: s.PublicKey()})
68+
}
69+
3670
func die(format string, args ...any) {
3771
fmt.Fprintf(os.Stderr, "cactus-keygen: "+format+"\n", args...)
3872
os.Exit(1)

config-witness-example.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"data_dir": "/tmp/cactus-data",
3+
"log": {
4+
"number": 1,
5+
"shortname": "cactus-test",
6+
"hash": "sha256",
7+
"checkpoint_period_ms": 1000,
8+
"pool_size": 256
9+
},
10+
"ca_cosigner": {
11+
"id": "44363.47.1.99",
12+
"algorithm": "mldsa-44",
13+
"seed_path": "keys/ca-cosigner.seed"
14+
},
15+
"acme": {
16+
"listen": ":14000",
17+
"external_url": "http://localhost:14000",
18+
"challenge_mode": "auto-pass"
19+
},
20+
"monitoring": {
21+
"listen": ":14080",
22+
"external_url": "http://localhost:14080"
23+
},
24+
"metrics": {
25+
"listen": "127.0.0.1:14090"
26+
},
27+
"landmarks": {
28+
"enabled": true,
29+
"time_between_landmarks_ms": 60000,
30+
"max_cert_lifetime_ms": 3600000,
31+
"landmark_url_path": "/landmarks"
32+
},
33+
"mirror": {
34+
"enabled": true,
35+
"cosigner_id": "44363.47.2.1",
36+
"seed_path": "keys/witness-cosigner.seed",
37+
"algorithm": "mldsa-44",
38+
"upstream": {
39+
"tile_url": "http://127.0.0.1:14080/1",
40+
"log_id": "44363.47.1.99.0.1",
41+
"ca_cosigner_id": "44363.47.1.99",
42+
"ca_cosigner_key_pem": "-----BEGIN PUBLIC KEY-----\nREPLACE_WITH: cactus-keygen -pub -o keys/ca-cosigner.seed\n-----END PUBLIC KEY-----\n",
43+
"poll_interval_ms": 250
44+
},
45+
"sign_subtree_listen": "127.0.0.1:14081",
46+
"sign_subtree_path": "/sign-subtree",
47+
"require_ca_signature_on_subtree": true
48+
},
49+
"ca_cosigner_quorum": {
50+
"mirrors": [
51+
{
52+
"id": "44363.47.2.1",
53+
"url": "http://127.0.0.1:14081/sign-subtree",
54+
"algorithm": "mldsa-44",
55+
"public_key_pem": "-----BEGIN PUBLIC KEY-----\nREPLACE_WITH: cactus-keygen -pub -o keys/witness-cosigner.seed\n-----END PUBLIC KEY-----\n"
56+
}
57+
],
58+
"min_signatures": 1,
59+
"request_timeout_ms": 2000,
60+
"best_effort_after_minimum": false,
61+
"mirror_retry_deadline_ms": 8000
62+
},
63+
"log_level": "info"
64+
}

docs/test-instance.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Running a standalone test instance (CA + one witness cosigner)
2+
3+
This describes a single-process cactus deployment that issues
4+
certificates carrying **the CA cosignature plus one witness
5+
cosignature**, so an ACME client can request standalone and
6+
landmark-relative certificates and you can verify them against the log.
7+
8+
A single cactus process is always a full CA (issuance log + ACME server
9+
+ CA cosigner). The `mirror` block adds **one** witness/mirror cosigner
10+
in the same process, and `ca_cosigner_quorum` makes the CA collect that
11+
witness's cosignature — over loopback — for every subtree it signs. With
12+
`min_signatures: 1`, each issued standalone certificate ends up with two
13+
cosignatures: the CA's and the witness's.
14+
15+
> **This is not real transparency.** A witness operated by the CA on the
16+
> same host proves nothing a relying party should trust — independence is
17+
> the whole point of a witness. This setup exists to exercise the
18+
> cosignature wire formats and the relying-party verification paths on a
19+
> public *test* instance. See [threat-model.md](threat-model.md).
20+
21+
The sample config is [`config-witness-example.json`](../config-witness-example.json).
22+
23+
## How the pieces wire together
24+
25+
```
26+
┌──────────────────────── one cactus process ───────────────────────┐
27+
ACME client ─┼─▶ :14000 ACME ──▶ issuance log ──▶ CA cosigner (id 44363.47.1.99)│
28+
│ │ signs each subtree │
29+
│ :14080 monitoring (tiles, checkpoint, /landmarks) │
30+
│ ▲ │ CA quorum requester │
31+
│ │ follows ▼ POST /sign-subtree │
32+
│ witness follower ──▶ :14081 witness cosigner (id 44363.47.2.1) │
33+
└─────────────────────────────────────────────────────────────────────┘
34+
```
35+
36+
- The witness **follows** the CA's own log over loopback
37+
(`mirror.upstream.tile_url` = `http://127.0.0.1:14080/1`) and verifies
38+
the CA's checkpoint cosignatures.
39+
- On each checkpoint the CA's quorum requester sends a `sign-subtree`
40+
request — including its own CA cosignature — to the witness over
41+
loopback (`ca_cosigner_quorum.mirrors[0].url` =
42+
`http://127.0.0.1:14081/sign-subtree`). `require_ca_signature_on_subtree`
43+
can stay `true`: the CA supplies that cosignature.
44+
- All identifiers use the **relative** trust-anchor-ID form (the arcs
45+
below the `1.3.6.1.4.1` enterprise base). The log ID is the CA ID with
46+
`.0.<log number>` appended, e.g. `44363.47.1.99.0.1`.
47+
48+
## Setup
49+
50+
```sh
51+
# 0. Build (cactus needs Go 1.27 / gotip; see README).
52+
make build # ./bin/cactus, cactus-cli, cactus-keygen
53+
54+
# 1. Pick a data dir and create the keys dir.
55+
export DATA_DIR=/tmp/cactus-data
56+
mkdir -p "$DATA_DIR/keys"
57+
58+
# 2. Generate two distinct cosigner seeds — one for the CA, one for the
59+
# witness. They MUST differ (the config validator enforces this).
60+
./bin/cactus-keygen -o "$DATA_DIR/keys/ca-cosigner.seed"
61+
./bin/cactus-keygen -o "$DATA_DIR/keys/witness-cosigner.seed"
62+
63+
# 3. Start from the sample config and point it at $DATA_DIR.
64+
cp config-witness-example.json config.json
65+
sed -i "s|/tmp/cactus-data|$DATA_DIR|" config.json
66+
67+
# 4. Fill in the two public keys. `-pub` prints the PEM block whose body
68+
# is the raw public key — exactly what the config fields take.
69+
# - ca_cosigner.seed -> mirror.upstream.ca_cosigner_key_pem
70+
# - witness seed -> ca_cosigner_quorum.mirrors[0].public_key_pem
71+
./bin/cactus-keygen -pub -o "$DATA_DIR/keys/ca-cosigner.seed"
72+
./bin/cactus-keygen -pub -o "$DATA_DIR/keys/witness-cosigner.seed"
73+
# Paste each PEM into the matching field of config.json (a JSON string,
74+
# newlines escaped as \n, ending in \n).
75+
76+
# 5. Run.
77+
./bin/cactus -config config.json
78+
```
79+
80+
On startup the witness follower briefly logs one `connection refused`
81+
while the monitoring listener finishes binding, then catches up; that is
82+
harmless. `curl http://localhost:14090/metrics | grep cactus_mirror`
83+
shows `cactus_mirror_consistency_failures_total 0` once it is healthy.
84+
85+
### Public exposure
86+
87+
`acme.listen`/`monitoring.listen` are the public surfaces; set their
88+
`external_url` to whatever clients reach (cactus speaks plaintext HTTP,
89+
so terminate TLS at a reverse proxy if you need HTTPS, and set
90+
`external_url` to the public `https://…`). Keep `metrics.listen` and
91+
`mirror.sign_subtree_listen` on `127.0.0.1` — they are internal. The
92+
loopback URLs inside the `mirror`/`ca_cosigner_quorum` blocks must stay
93+
`127.0.0.1` regardless of the public `external_url`.
94+
95+
`challenge_mode: auto-pass` makes every authorization instantly valid, so
96+
anyone who can reach the ACME port gets a cert for any name — fine for a
97+
test instance, but switch to `http-01` if that matters.
98+
99+
## Issuing certificates
100+
101+
Use any RFC 8555 client against `<acme external_url>/directory`, e.g.:
102+
103+
```sh
104+
lego --server http://localhost:14000/directory \
105+
--email you@example.com --domains example.test \
106+
--accept-tos --pem --path ./certs run
107+
```
108+
109+
The order's `certificate` URL is the **standalone** cert (CA + witness
110+
cosignatures). The `Link: …; rel="alternate"` header on the finalize and
111+
certificate responses points at the **landmark-relative** variant
112+
(`/cert/{id}/alternate`), which is signature-free and verifies against
113+
predistributed landmark subtree hashes. Both cert URLs are POST-as-GET
114+
resources (RFC 8555 §6.3), so an ACME client — not a plain `curl`
115+
retrieves them; the alternate returns `503 + Retry-After` until a
116+
covering landmark exists. With `time_between_landmarks_ms: 60000` a
117+
covering landmark appears within ~a minute of issuance; lower it for
118+
snappier testing (at the cost of more active landmarks).
119+
120+
## Verifying against the log
121+
122+
`cactus-cli` talks to the monitoring base plus the log number — here
123+
`http://localhost:14080/1`:
124+
125+
```sh
126+
# Current signed checkpoint (origin / size / root).
127+
curl http://localhost:14080/1/checkpoint
128+
./bin/cactus-cli tree show http://localhost:14080/1
129+
130+
# Walk every tile, recompute the root, compare to the signed checkpoint.
131+
./bin/cactus-cli tree verify http://localhost:14080/1
132+
133+
# Inspect one entry (N = tree size - 1 for the most recent).
134+
./bin/cactus-cli entry http://localhost:14080/1 N
135+
136+
# Full §7.2 verification of an issued cert against the live log: splits
137+
# the cert, decodes the MTCProof, recomputes the leaf hash, evaluates the
138+
# inclusion proof, and compares to the log's subtree hash. Prints OK.
139+
# Works for both the standalone and the landmark-relative certificate
140+
# (the inclusion-proof check is independent of the cosignatures).
141+
./bin/cactus-cli cert verify ./certs/example.test.crt http://localhost:14080/1
142+
143+
# Machine-readable inclusion proof for scripting.
144+
./bin/cactus-cli prove http://localhost:14080/1 N | jq .
145+
```
146+
147+
The standalone certificate's `MTCProof` carries two cosignatures (the CA
148+
and the witness, sorted by cosigner ID); the landmark-relative one
149+
carries none. `cactus-cli cert verify` confirms either is correctly
150+
logged; trusting the *witness* cosignature specifically is a
151+
relying-party policy decision and requires the witness public key
152+
(`cactus-keygen -pub -o keys/witness-cosigner.seed`).

0 commit comments

Comments
 (0)