Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa1fb1143 | ||
|
|
276fe5e438 | ||
|
|
98ff17002f | ||
|
|
9aa0117af4 | ||
|
|
3de20ed89d |
@@ -14,7 +14,7 @@
|
|||||||
- Invariants I1–I9 (§2) are normative; proposals contradicting them (scores in responses, topic taxonomy, announce stream, dispute messages, replayable broadcast, in-protocol pricing/settlement) are out of scope by design.
|
- Invariants I1–I9 (§2) are normative; proposals contradicting them (scores in responses, topic taxonomy, announce stream, dispute messages, replayable broadcast, in-protocol pricing/settlement) are out of scope by design.
|
||||||
- Appendix B (Purge Log) is normative: a rejected mechanism may only be re-proposed if the written rationale is addressed.
|
- Appendix B (Purge Log) is normative: a rejected mechanism may only be re-proposed if the written rationale is addressed.
|
||||||
- §10 Open Issues are known gaps, not oversights (e.g., signature canonicalization blocks Phase-1 interop). Check it before "fixing" something.
|
- §10 Open Issues are known gaps, not oversights (e.g., signature canonicalization blocks Phase-1 interop). Check it before "fixing" something.
|
||||||
- Use the spec's vocabulary — member/querier/responder, aggregates, source/enrichment members — not client/server or search-engine terms.
|
- Use the spec's vocabulary — member/querier/responder, aggregates — not client/server or search-engine terms.
|
||||||
- A new `MUST` is only legitimate if it is observable at the boundary, deterministically verifiable by a peer, beneficial to the counterparty, and not derivable from local policy. Ranking/ordering/presentation fails this test and stays local (§5); scores never travel (I6).
|
- A new `MUST` is only legitimate if it is observable at the boundary, deterministically verifiable by a peer, beneficial to the counterparty, and not derivable from local policy. Ranking/ordering/presentation fails this test and stays local (§5); scores never travel (I6).
|
||||||
- I2 is not blanket anti-centralization: shared coordination (identity, admission, contract) is centralized in the MA because common state is cheaper held once; decisions that consume local information (matching, relevance, sharing, retention) stay local. Off-wire conduct (link handling, retention, gating) is contract, not conformance.
|
- I2 is not blanket anti-centralization: shared coordination (identity, admission, contract) is centralized in the MA because common state is cheaper held once; decisions that consume local information (matching, relevance, sharing, retention) stay local. Off-wire conduct (link handling, retention, gating) is contract, not conformance.
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
- `add`/`reindex` reset a collection (delete by manifest `name`) before re-adding, so deleted files don't linger; collection identity is its name, and same-named collections replace each other.
|
- `add`/`reindex` reset a collection (delete by manifest `name`) before re-adding, so deleted files don't linger; collection identity is its name, and same-named collections replace each other.
|
||||||
- Relay backpressure is global: any member's full queue 429s every publisher until drained (visible per §3, but one lagging member can stall the firehose — revisit before scale).
|
- Relay backpressure is global: any member's full queue 429s every publisher until drained (visible per §3, but one lagging member can stall the firehose — revisit before scale).
|
||||||
- Member authority (Draft 0.5 §6): the MA-signed registry snapshot is authoritative when configured (`[node] registry` = file path or URL, `ma_key` pinned; monotonic version — rollback and forgery close the node; file path is mtime-reloaded, URL is fetched at start + every 60s and cached to `<data_dir>/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`.
|
- Member authority (Draft 0.5 §6): the MA-signed registry snapshot is authoritative when configured (`[node] registry` = file path or URL, `ma_key` pinned; monotonic version — rollback and forgery close the node; file path is mtime-reloaded, URL is fetched at start + every 60s and cached to `<data_dir>/registry-cache.json`, so outage fails static). Keys carry optional validity windows (`not_before`/`not_after`); rotation = `registry add-key` then `revoke-key`.
|
||||||
- `<data_dir>/members.toml` (name, pubkey, class, `previous` keys, mtime-reloaded) is a dev/local fallback used only when no registry is configured; empty directory without a registry is open bootstrap only when `dev_bootstrap = true` (RFC §6: explicit dev flag). Receivers drop content-bearing responses from enrichment-class senders (metadata-only, §6).
|
- `<data_dir>/members.toml` (name, pubkey, `previous` keys, mtime-reloaded) is a dev/local fallback used only when no registry is configured; empty directory without a registry is open bootstrap only when `dev_bootstrap = true` (RFC §6: explicit dev flag).
|
||||||
- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|applications|set-relays|show|serve` (signed `registry.json` + `ma-key.hex` in `--dir`); `frxd init --id/--registry/--ma-key`; `frxd key show|rotate`; `member add --previous <old>` for the fallback path. A node with no `[node] relays` discovers them from the registry snapshot (`doc.relays`).
|
- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|applications|approve|invite|token|revoke-token|set-relays|show|serve` (signed `registry.json` + `ma-key.hex` in `--dir`); `frxd init --id/--registry/--ma-key`; `frxd key show|rotate`; `member add --previous <old>` for the fallback path. A node with no `[node] relays` discovers them from the registry snapshot (`doc.relays`).
|
||||||
- Aggregate semantics are our implementation choices from a terse spec: requests are `aggregate` envelopes carrying only `period`; replies carry `sent` (broadcasts that month) / `passed` (responses consumed from that member); granularity floor is enforced as YYYY or YYYY-MM only (finer rejected), yearly rolls up months. Revisit with §10 sufficiency review.
|
- Aggregate semantics are our implementation choices from a terse spec: requests are `aggregate` envelopes carrying only `period`; replies carry `sent` (broadcasts that month) / `passed` (responses consumed from that member); granularity floor is enforced as YYYY or YYYY-MM only (finer rejected), yearly rolls up months. Revisit with §10 sufficiency review.
|
||||||
|
|
||||||
## Known gaps (Phase 2/3, intentional — don't fake them)
|
## Known gaps (Phase 2/3, intentional — don't fake them)
|
||||||
@@ -43,13 +43,13 @@
|
|||||||
|
|
||||||
## Technical plans (deliberately not in the RFC)
|
## Technical plans (deliberately not in the RFC)
|
||||||
- Record plans here — not as spec edits — when they are implementation/demo choices rather than protocol surface.
|
- Record plans here — not as spec edits — when they are implementation/demo choices rather than protocol surface.
|
||||||
- Demo plan: build a useful end-to-end demo on GDELT and Common Crawl (CC-NEWS; sometimes called "OpenCrawl" in discussion) as enrichment members / backfill seeding. RFC §6 and Appendix A already name both as example derived corpora, so no new mechanisms are required; enrichment members are metadata-only exposure.
|
- Demo plan: build a useful end-to-end demo on GDELT and Common Crawl (CC-NEWS; sometimes called "OpenCrawl" in discussion) as ordinary members / backfill seeding; Appendix A names both as example derived corpora. Derived corpora are metadata-only via the member's own `exposure=metadata` collection setting (I9) — there is no registry-level class.
|
||||||
- Phase 1 (two-node query/response) is built and tested; the enrichment demo layers on top of it.
|
- Phase 1 (two-node query/response) is built and tested; the derived-corpora demo layers on top of it.
|
||||||
- Language: Rust (settled, matches §7). Decided by the engine requirement, not preference: Tantivy gives in-process Lucene-class BM25 + incremental indexing; C/C++ embedded alternatives are worse (Xapian GPL-2+, CLucene unmaintained, SQLite FTS5 thin), plus single static musl binaries for the install story and memory safety on the untrusted network/crypto path. Don't re-litigate.
|
- Language: Rust (settled, matches §7). Decided by the engine requirement, not preference: Tantivy gives in-process Lucene-class BM25 + incremental indexing; C/C++ embedded alternatives are worse (Xapian GPL-2+, CLucene unmaintained, SQLite FTS5 thin), plus single static musl binaries for the install story and memory safety on the untrusted network/crypto path. Don't re-litigate.
|
||||||
- frxd modes (one binary, config toggles, no code required of publishers): querier (broadcast/local-first search), responder (match incoming queries against shared collections, sign), local index (watch dirs, extract text, explicit shared marking per I9). Use RFC terms querier/responder, not "subscriber/publisher".
|
- frxd modes (one binary, config toggles, no code required of publishers): querier (broadcast/local-first search), responder (match incoming queries against shared collections, sign), local index (watch dirs, extract text, explicit shared marking per I9). Use RFC terms querier/responder, not "subscriber/publisher".
|
||||||
- Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role.
|
- Roles are not exclusive: a single node may issue queries and answer them concurrently (I5, §3 "any member"). Implement querier/responder as independent enable flags — never an exclusive mode enum or fixed deployment role.
|
||||||
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 1–2 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
|
- Matching floor: boundary tokenizer (`src/tokenizer.rs` — letter/digit splits so `5555` matches `DLEX5555`, lowercase, ASCII fold, English stopwords+stemmer) → coverage gate (`[match] min_coverage`, default 0.4; 1–2 term queries require all terms) → title boost 2.0 + phrase boost 3.0 + query-time snippets. Schema changes require a fresh index dir (`open_or_create` errors on mismatch).
|
||||||
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search` → `EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine).
|
- Engine seam: `src/engine.rs` `SearchEngine` trait (`search` → `EngineOutput { hits, total: Option<u64> }`, `doc_count`); `respond()` in `src/node.rs` is the conformance wrapper (budget clamp, truncation from engine total — unknown total forces `truncated = true`). Power users can implement the trait (HTTP adapter or subprocess to an external engine).
|
||||||
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA's signup endpoint (`registry serve --signup-code --registry-url`; HTML page at `/`, `POST /v1/signup` → one-time invite token, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Invites live in `<registry dir>/invites.json`; the form's organization/representative/contact/payment fields are stored privately in `<registry dir>/applications.json` (mode 600, MA contract data — never in the signed snapshot), and the two acknowledgement checkboxes are required by the endpoint. Prompts accept empty input as the default; scripted stdin works for tests.
|
- Onboarding: `frxd --onboarding` runs a wizard consuming a credential block (`id=.. token=.. registry=.. ma_key=..`) issued by the MA (`registry serve`; HTML page at `/`, `POST /v1/signup` queues a pending application, `POST /v1/enroll` binds keys and re-signs). Identity registration stays MA-side; the wizard never creates identities, only binds locally generated keys. Applications live in `<registry dir>/applications.json` (mode 600, MA contract data — never in the signed snapshot); `frxd registry approve <id>` promotes one (member stub + credential block whose token is the member's reusable account credential, hashed in `<registry dir>/tokens.json` — authorizes key enrollment for every node the member runs). `frxd registry token <id>` mints another member token, `revoke-token <id>` revokes all of a member's tokens; `frxd registry invite <id>` mints a single-use 24h handoff token (`<registry dir>/invites.json`). Prompts accept empty input as the default; scripted stdin works for tests.
|
||||||
- Next matching steps: eval harness with a small golden set (precision@k + false-silence rate), then a dense recall leg (model2vec-rs 0.2.1 exists but needs `default-features = false, features = ["fancy-regex", "local-only"]` for musl/airgapped; verify crate + model licenses before bundling), then an optional cross-encoder reranker. Embeddings are for recall; reranking is the precision tier.
|
- Next matching steps: eval harness with a small golden set (precision@k + false-silence rate), then a dense recall leg (model2vec-rs 0.2.1 exists but needs `default-features = false, features = ["fancy-regex", "local-only"]` for musl/airgapped; verify crate + model licenses before bundling), then an optional cross-encoder reranker. Embeddings are for recall; reranking is the precision tier.
|
||||||
- Identity/registry (RFC Draft 0.5 §4/§6): MA-hosted FQDN identifiers first (`<label>.frx.<ma-domain>`, no DNS needed by users), signed versioned registry snapshot with the MA key pinned; envelope `from` = identifier, `key` = pubkey; registry outage fails static. Member-hosted identities, MA anchor rollover, and unicast confidentiality are §10 open. Implementation phases: A (signed registry snapshot) and B (identifier + `key` + JCS on the wire) are built and tested. Prioritize frictionless onboarding (users may be department-level and cannot create DNS).
|
- Identity/registry (RFC Draft 0.5 §4/§6): MA-hosted FQDN identifiers first (`<label>.frx.<ma-domain>`, no DNS needed by users), signed versioned registry snapshot with the MA key pinned; envelope `from` = identifier, `key` = pubkey; registry outage fails static. Member-hosted identities, MA anchor rollover, and unicast confidentiality are §10 open. Implementation phases: A (signed registry snapshot) and B (identifier + `key` + JCS on the wire) are built and tested. Prioritize frictionless onboarding (users may be department-level and cannot create DNS).
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ MA operator — run the signup site:
|
|||||||
|
|
||||||
```
|
```
|
||||||
frxd registry --dir ./ma init --zone frx.federatedsearch.org
|
frxd registry --dir ./ma init --zone frx.federatedsearch.org
|
||||||
frxd registry --dir ./ma serve --listen 127.0.0.1:7800 --signup-code <code> --registry-url https://ma.federatedsearch.org/registry.json
|
frxd registry --dir ./ma serve --listen 127.0.0.1:7800
|
||||||
```
|
```
|
||||||
|
|
||||||
(put Caddy in front for a real domain). The page at `/` accepts the registration form (label, signup code, organization details) and returns a credential block: `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `<registry dir>/applications.json` — contract data, never in the public signed snapshot; review with `frxd registry applications`.
|
(put Caddy in front for a real domain). The page at `/` collects the registration form (short name, organization details) and queues it for MA review — `frxd registry --dir <dir> applications` lists applications and `frxd registry --dir <dir> approve <id> --registry-url <url>` creates the member, mints its account credential, and prints the credential block to hand over. The token is reusable: it authorizes key enrollment for every node the member runs (`registry token <id>` mints an additional one; `registry revoke-token <id>` revokes all after a leak). A single-use 24h invite (`registry invite <id>`) remains for constrained handoffs. The block is `id=... token=... registry=... ma_key=...`. Organization details (legal name, representative, contacts, payment) are recorded privately by the MA in `<registry dir>/applications.json` — contract data, never in the public signed snapshot.
|
||||||
|
|
||||||
New member:
|
New member:
|
||||||
|
|
||||||
@@ -169,8 +169,8 @@ gitea admin user generate-access-token -u <you> -t bootstrap --scopes all --conf
|
|||||||
```
|
```
|
||||||
|
|
||||||
The org is `frx`, the repo `frxd` → clone URL
|
The org is `frx`, the repo `frxd` → clone URL
|
||||||
`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (signup code);
|
`https://git.federatedsearch.org/frx/frxd.git`. Membership stays closed (MA-approved
|
||||||
repo reads are public.
|
applications); repo reads are public.
|
||||||
|
|
||||||
Publishing a release (from the checkout):
|
Publishing a release (from the checkout):
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Any member may originate queries and answer them; roles are enable flags, never
|
|||||||
|
|
||||||
- **Member node (`frxd serve`)** — owns a keypair and an identifier, indexes local collections, broadcasts queries, answers queries from shared collections, receives responses. Local-first: local results are merged with remote results, provenance-marked.
|
- **Member node (`frxd serve`)** — owns a keypair and an identifier, indexes local collections, broadcasts queries, answers queries from shared collections, receives responses. Local-first: local results are merged with remote results, provenance-marked.
|
||||||
- **Relay (`frxd relay`)** — dumb, interchangeable transport. Holds no history, replays nothing, fans queries out to subscribed members, carries unicast responses/aggregates to member mailboxes. Relays may peer with each other to flood queries.
|
- **Relay (`frxd relay`)** — dumb, interchangeable transport. Holds no history, replays nothing, fans queries out to subscribed members, carries unicast responses/aggregates to member mailboxes. Relays may peer with each other to flood queries.
|
||||||
- **Registry (MA)** — the membership authority: a signed, versioned snapshot listing identifiers, classes, authorized keys with validity windows, optional X25519 encryption keys, and relay endpoints. The registry is the sole authority for key-to-identifier binding.
|
- **Registry (MA)** — the membership authority: a signed, versioned snapshot listing identifiers, authorized keys with validity windows, optional X25519 encryption keys, and relay endpoints. The registry is the sole authority for key-to-identifier binding.
|
||||||
|
|
||||||
## 3. Message flow
|
## 3. Message flow
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Protocol-silent by design (I2): ranking, ordering, presentation, relevance gatin
|
|||||||
|
|
||||||
6. Membership
|
6. Membership
|
||||||
|
|
||||||
The MA governs identity, contract, expulsion — who, never quality. Identifiers are MA-hosted FQDNs (`<label>.frx.<ma-domain>`); no member-controlled DNS is required. Member-hosted identifiers — keys published in the member's own domain and allowlisted by the MA — are planned, not yet normative. The MA maintains a signed, versioned registry snapshot listing identifiers, class, authorized keys with validity windows, an optional X25519 encryption key per member, and the federation's relay endpoints. Members and queriers may discover relays from it; relays MAY verify sender admission against it, rejecting unlisted keys visibly. Nodes pin the MA key; the snapshot is the sole authority for the key→identifier binding. Rotation publishes a successor key before retiring its predecessor; revocation removes a key or shortens its validity. Registry outage is fail-static: the last validated snapshot stays in force, and open bootstrap requires an explicit development flag. Admission cost is the Sybil defense. Expulsion grounds: fabrication, admission fraud, sustained abuse — never low quality. Escalation: local throttle → advisory aggregates → MA warning → delisting → expulsion. Aggregates are inadmissible as sanction evidence (I4). Conduct not observable on the wire — link handling, retention, gating — is governed by contract; the protocol neither observes nor adjudicates it. Membership classes: source members (own content) and enrichment members (derived corpora, e.g. GDELT/CC-NEWS bots — metadata-only exposure, transformation logic open and auditable).
|
The MA governs identity, contract, expulsion — who, never quality. Identifiers are MA-hosted FQDNs (`<label>.frx.<ma-domain>`); no member-controlled DNS is required. Member-hosted identifiers — keys published in the member's own domain and allowlisted by the MA — are planned, not yet normative. The MA maintains a signed, versioned registry snapshot listing identifiers, authorized keys with validity windows, an optional X25519 encryption key per member, and the federation's relay endpoints. Members and queriers may discover relays from it; relays MAY verify sender admission against it, rejecting unlisted keys visibly. Nodes pin the MA key; the snapshot is the sole authority for the key→identifier binding. Rotation publishes a successor key before retiring its predecessor; revocation removes a key or shortens its validity. Registry outage is fail-static: the last validated snapshot stays in force, and open bootstrap requires an explicit development flag. Admission cost is the Sybil defense. Expulsion grounds: fabrication, admission fraud, sustained abuse — never low quality. Escalation: local throttle → advisory aggregates → MA warning → delisting → expulsion. Aggregates are inadmissible as sanction evidence (I4). Conduct not observable on the wire — link handling, retention, gating — is governed by contract; the protocol neither observes nor adjudicates it.
|
||||||
|
|
||||||
7. Reference Implementation — frxd
|
7. Reference Implementation — frxd
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Build order: Phase 1 — envelope, query stream, query, response (demoable betwe
|
|||||||
|
|
||||||
8. Security & Privacy Considerations
|
8. Security & Privacy Considerations
|
||||||
|
|
||||||
Query visibility is total among members; abstraction level and membership are the boundary (I3). Derived queries SHOULD minimize personal data (I3); each member is responsible for the content of its own messages. Response streams are strategic disclosure (corpus mapping, intake intelligence) — unicast, need-to-know. Amplification is bounded by bilateral transport limits and contract, not routing. Publisher self-promotion is the expected adversarial mode; defense is local (gate, pass-rate throttle, local source reputation). Enrichment members' filters are an editorial power — auditable openness is the mitigation.
|
Query visibility is total among members; abstraction level and membership are the boundary (I3). Derived queries SHOULD minimize personal data (I3); each member is responsible for the content of its own messages. Response streams are strategic disclosure (corpus mapping, intake intelligence) — unicast, need-to-know. Amplification is bounded by bilateral transport limits and contract, not routing. Publisher self-promotion is the expected adversarial mode; defense is local (gate, pass-rate throttle, local source reputation). Derived-corpus members' filters are an editorial power — auditable openness is the mitigation.
|
||||||
|
|
||||||
9. Conformance
|
9. Conformance
|
||||||
|
|
||||||
|
|||||||
+120
-113
@@ -10,7 +10,7 @@ use axum::{Json, Router};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Config, Member, load_members, save_members};
|
use crate::config::{Config, Member, load_members, save_members};
|
||||||
use crate::crypto::{Keypair, now_ts};
|
use crate::crypto::{Keypair, now_ts};
|
||||||
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
use crate::index::{Collection, LocalIndex, load_collections, save_collections};
|
||||||
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
use crate::message::{EXPOSURE_FULL, EXPOSURE_METADATA};
|
||||||
@@ -113,7 +113,6 @@ pub fn member_add(
|
|||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
class: &str,
|
|
||||||
previous: &[String],
|
previous: &[String],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let config = Config::load(config_path)?;
|
let config = Config::load(config_path)?;
|
||||||
@@ -122,24 +121,15 @@ pub fn member_add(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|key| normalize_key(key))
|
.map(|key| normalize_key(key))
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
let class = if class == CLASS_ENRICHMENT {
|
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
let mut members = load_members(&config.members_path())?;
|
let mut members = load_members(&config.members_path())?;
|
||||||
members.retain(|member| member.name != name && member.pubkey != pubkey);
|
members.retain(|member| member.name != name && member.pubkey != pubkey);
|
||||||
members.push(Member {
|
members.push(Member {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
pubkey,
|
pubkey,
|
||||||
class: class.to_string(),
|
|
||||||
previous,
|
previous,
|
||||||
});
|
});
|
||||||
save_members(&config.members_path(), &members)?;
|
save_members(&config.members_path(), &members)?;
|
||||||
println!(
|
println!("listed {name} in {}", config.members_path().display());
|
||||||
"listed {name} ({class}) in {}",
|
|
||||||
config.members_path().display()
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +194,7 @@ pub fn member_list(config_path: &Path) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for member in members {
|
for member in members {
|
||||||
println!("{} [{}] {}", member.name, member.class, member.pubkey);
|
println!("{} {}", member.name, member.pubkey);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -304,25 +294,18 @@ pub fn registry_add(
|
|||||||
dir: &Path,
|
dir: &Path,
|
||||||
id: &str,
|
id: &str,
|
||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
class: &str,
|
|
||||||
not_before: Option<u64>,
|
not_before: Option<u64>,
|
||||||
not_after: Option<u64>,
|
not_after: Option<u64>,
|
||||||
enc_key: Option<String>,
|
enc_key: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let pubkey = normalize_key(pubkey)?;
|
let pubkey = normalize_key(pubkey)?;
|
||||||
let enc_key = enc_key.map(|key| normalize_key(&key)).transpose()?;
|
let enc_key = enc_key.map(|key| normalize_key(&key)).transpose()?;
|
||||||
let class = if class == CLASS_ENRICHMENT {
|
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
mutate_registry(dir, |doc| {
|
mutate_registry(dir, |doc| {
|
||||||
if doc.members.iter().any(|member| member.id == id) {
|
if doc.members.iter().any(|member| member.id == id) {
|
||||||
return Err(anyhow!("member {id} already listed"));
|
return Err(anyhow!("member {id} already listed"));
|
||||||
}
|
}
|
||||||
doc.members.push(RegistryMember {
|
doc.members.push(RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: pubkey.clone(),
|
key: pubkey.clone(),
|
||||||
not_before: not_before.unwrap_or_else(now_ts),
|
not_before: not_before.unwrap_or_else(now_ts),
|
||||||
@@ -332,7 +315,7 @@ pub fn registry_add(
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
println!("added {id} ({class})");
|
println!("added {id}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +392,7 @@ pub fn registry_list(dir: &Path) -> Result<()> {
|
|||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
for member in &signed.doc.members {
|
for member in &signed.doc.members {
|
||||||
let keys = member.keys.len();
|
let keys = member.keys.len();
|
||||||
println!("{} [{}] ({} key(s))", member.id, member.class, keys);
|
println!("{} ({} key(s))", member.id, keys);
|
||||||
for entry in &member.keys {
|
for entry in &member.keys {
|
||||||
let window = match entry.not_after {
|
let window = match entry.not_after {
|
||||||
Some(end) => format!("valid {}..{}", entry.not_before, end),
|
Some(end) => format!("valid {}..{}", entry.not_before, end),
|
||||||
@@ -431,7 +414,7 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
for app in &applications {
|
for app in &applications {
|
||||||
println!("{} [{}] {} <{}>", app.id, app.class, app.org, app.email);
|
println!("{} {} <{}> — {}", app.id, app.org, app.email, app.status);
|
||||||
println!(" representative: {}", app.representative);
|
println!(" representative: {}", app.representative);
|
||||||
if !app.address.is_empty() {
|
if !app.address.is_empty() {
|
||||||
println!(" address: {}", app.address);
|
println!(" address: {}", app.address);
|
||||||
@@ -449,6 +432,68 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Approves a pending application: creates the member entry, mints its account
|
||||||
|
/// credential (member token), and prints the credential block to hand over.
|
||||||
|
pub fn registry_approve(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<()> {
|
||||||
|
let (_, signed) = open_registry(dir)?;
|
||||||
|
if signed.doc.members.iter().any(|member| member.id == id) {
|
||||||
|
return Err(anyhow!("member {id} already listed"));
|
||||||
|
}
|
||||||
|
let _application = registry::approve_application(dir, id)?;
|
||||||
|
mutate_registry(dir, |doc| {
|
||||||
|
doc.members.push(RegistryMember {
|
||||||
|
id: id.to_string(),
|
||||||
|
keys: Vec::new(),
|
||||||
|
enc_key: None,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
let token = registry::create_token(dir, id)?;
|
||||||
|
let (_, signed) = open_registry(dir)?;
|
||||||
|
println!("approved {id}");
|
||||||
|
println!("member token — reusable for every node the member runs; keep private:");
|
||||||
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a fresh invite for an existing member — one single-use token per node
|
||||||
|
/// the member runs (each node binds its own key at enrollment).
|
||||||
|
pub fn registry_invite(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<()> {
|
||||||
|
let (_, signed) = open_registry(dir)?;
|
||||||
|
if !signed.doc.members.iter().any(|member| member.id == id) {
|
||||||
|
return Err(anyhow!("no member named {id}"));
|
||||||
|
}
|
||||||
|
let invite = registry::create_invite(dir, id, 24 * 3600)?;
|
||||||
|
println!("single-use handoff invite for {id} (valid 24h):");
|
||||||
|
print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mints an additional member token (account credential) and prints the block.
|
||||||
|
/// Returns the raw token for programmatic use.
|
||||||
|
pub fn registry_token(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<String> {
|
||||||
|
let (_, signed) = open_registry(dir)?;
|
||||||
|
if !signed.doc.members.iter().any(|member| member.id == id) {
|
||||||
|
return Err(anyhow!("no member named {id}"));
|
||||||
|
}
|
||||||
|
let token = registry::create_token(dir, id)?;
|
||||||
|
println!("member token for {id} — reusable for every node they run; keep private:");
|
||||||
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes all of a member's tokens (e.g. after a leak); mint fresh with `registry token`.
|
||||||
|
pub fn registry_revoke_token(dir: &Path, id: &str) -> Result<()> {
|
||||||
|
let revoked = registry::revoke_tokens(dir, id)?;
|
||||||
|
println!("revoked {revoked} token(s) for {id}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_credential_block(id: &str, token: &str, registry_url: Option<&str>, ma_key: &str) {
|
||||||
|
let registry_url = registry_url.unwrap_or("<registry-url>");
|
||||||
|
println!("id={id} token={token} registry={registry_url} ma_key={ma_key}");
|
||||||
|
}
|
||||||
|
|
||||||
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
|
pub fn registry_set_relays(dir: &Path, relays: &[String]) -> Result<()> {
|
||||||
mutate_registry(dir, |doc| {
|
mutate_registry(dir, |doc| {
|
||||||
doc.relays = relays.to_vec();
|
doc.relays = relays.to_vec();
|
||||||
@@ -471,19 +516,11 @@ pub fn registry_show(dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
struct RegistryServer {
|
struct RegistryServer {
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
signup_code: Option<String>,
|
|
||||||
registry_url: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn registry_router(
|
pub fn registry_router(dir: &Path) -> Router {
|
||||||
dir: &Path,
|
|
||||||
signup_code: Option<String>,
|
|
||||||
registry_url: Option<String>,
|
|
||||||
) -> Router {
|
|
||||||
let state = std::sync::Arc::new(RegistryServer {
|
let state = std::sync::Arc::new(RegistryServer {
|
||||||
dir: dir.to_path_buf(),
|
dir: dir.to_path_buf(),
|
||||||
signup_code,
|
|
||||||
registry_url,
|
|
||||||
});
|
});
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(registry_health))
|
.route("/health", get(registry_health))
|
||||||
@@ -494,13 +531,8 @@ pub fn registry_router(
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn registry_serve(
|
pub async fn registry_serve(dir: &Path, listen: &str) -> Result<()> {
|
||||||
dir: &Path,
|
let app = registry_router(dir);
|
||||||
listen: &str,
|
|
||||||
signup_code: Option<String>,
|
|
||||||
registry_url: Option<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let app = registry_router(dir, signup_code, registry_url);
|
|
||||||
let listener = TcpListener::bind(listen).await?;
|
let listener = TcpListener::bind(listen).await?;
|
||||||
println!("registry serving on http://{}", listener.local_addr()?);
|
println!("registry serving on http://{}", listener.local_addr()?);
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
@@ -543,7 +575,6 @@ fn sanitize_label(input: &str) -> String {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct SignupRequest {
|
struct SignupRequest {
|
||||||
label: String,
|
label: String,
|
||||||
code: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
org: String,
|
org: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -555,8 +586,6 @@ struct SignupRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
domain: String,
|
domain: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
class: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
payment: String,
|
payment: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
privacy_link: String,
|
privacy_link: String,
|
||||||
@@ -570,17 +599,11 @@ async fn registry_signup(
|
|||||||
State(server): State<std::sync::Arc<RegistryServer>>,
|
State(server): State<std::sync::Arc<RegistryServer>>,
|
||||||
Json(request): Json<SignupRequest>,
|
Json(request): Json<SignupRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Some(expected) = &server.signup_code else {
|
let label = sanitize_label(&request.label);
|
||||||
|
if label.is_empty() {
|
||||||
return (
|
return (
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(serde_json::json!({ "error": "signup is not enabled" })),
|
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
};
|
|
||||||
if request.code.as_deref() != Some(expected.as_str()) {
|
|
||||||
return (
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
Json(serde_json::json!({ "error": "wrong signup code" })),
|
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -598,11 +621,13 @@ async fn registry_signup(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let label = sanitize_label(&request.label);
|
if request.org.trim().is_empty()
|
||||||
if label.is_empty() {
|
|| request.representative.trim().is_empty()
|
||||||
|
|| request.email.trim().is_empty()
|
||||||
|
{
|
||||||
return (
|
return (
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
Json(serde_json::json!({ "error": "label must be alphanumeric" })),
|
Json(serde_json::json!({ "error": "organization name, representative, and contact email are required" })),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -625,8 +650,9 @@ async fn registry_signup(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let invite = match registry::create_invite(&server.dir, &id, 24 * 3600) {
|
let applications = match registry::load_applications(®istry::applications_path(&server.dir))
|
||||||
Ok(invite) => invite,
|
{
|
||||||
|
Ok(applications) => applications,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -635,23 +661,10 @@ async fn registry_signup(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
|
if applications.iter().any(|application| application.id == id) {
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
if let Err(error) = mutate_registry(&server.dir, |doc| {
|
|
||||||
doc.members.push(RegistryMember {
|
|
||||||
id: id.clone(),
|
|
||||||
class: class.to_string(),
|
|
||||||
keys: Vec::new(),
|
|
||||||
enc_key: None,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::CONFLICT,
|
||||||
Json(serde_json::json!({ "error": error.to_string() })),
|
Json(serde_json::json!({ "error": "an application for this identifier is already on file" })),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
@@ -662,9 +675,9 @@ async fn registry_signup(
|
|||||||
email: request.email.clone(),
|
email: request.email.clone(),
|
||||||
address: request.address.clone(),
|
address: request.address.clone(),
|
||||||
domain: request.domain.clone(),
|
domain: request.domain.clone(),
|
||||||
class: class.to_string(),
|
|
||||||
payment: request.payment.clone(),
|
payment: request.payment.clone(),
|
||||||
privacy_link: request.privacy_link.clone(),
|
privacy_link: request.privacy_link.clone(),
|
||||||
|
status: "pending".to_string(),
|
||||||
submitted_at: now_ts(),
|
submitted_at: now_ts(),
|
||||||
};
|
};
|
||||||
if let Err(error) = registry::record_application(&server.dir, application) {
|
if let Err(error) = registry::record_application(&server.dir, application) {
|
||||||
@@ -674,19 +687,12 @@ async fn registry_signup(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let registry_url = server.registry_url.clone().unwrap_or_default();
|
|
||||||
let ma_key = signed.doc.ma_key.clone();
|
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::ACCEPTED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
|
"status": "pending",
|
||||||
"id": id,
|
"id": id,
|
||||||
"token": invite.token,
|
"message": "application received — the membership authority reviews it and issues your credential block"
|
||||||
"registry": registry_url,
|
|
||||||
"ma_key": ma_key,
|
|
||||||
"credentials": format!(
|
|
||||||
"id={id} token={} registry={registry_url} ma_key={ma_key}",
|
|
||||||
invite.token
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -724,12 +730,16 @@ async fn registry_enroll(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = registry::redeem_invite(&server.dir, &request.id, &request.token) {
|
if registry::redeem_invite(&server.dir, &request.id, &request.token).is_err() {
|
||||||
return (
|
let valid = registry::validate_token(&server.dir, &request.id, &request.token)
|
||||||
StatusCode::FORBIDDEN,
|
.unwrap_or(false);
|
||||||
Json(serde_json::json!({ "error": error.to_string() })),
|
if !valid {
|
||||||
)
|
return (
|
||||||
.into_response();
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(serde_json::json!({ "error": "unknown invite or member token" })),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let result = mutate_registry(&server.dir, |doc| {
|
let result = mutate_registry(&server.dir, |doc| {
|
||||||
let Some(member) = doc
|
let Some(member) = doc
|
||||||
@@ -816,20 +826,17 @@ scores on the wire, no in-protocol payment.</p>
|
|||||||
|
|
||||||
<h2>1. Register with the membership authority</h2>
|
<h2>1. Register with the membership authority</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p class="muted">Registering here creates your identifier with the membership authority (MA); the
|
<p class="muted">This form requests membership from the membership authority (MA). Membership has no
|
||||||
onboarding wizard in step 4 then binds your node's keys to it. The public registry publishes only
|
roles or tiers: every member may broadcast queries and every member may answer them. The MA
|
||||||
your identifier, class, keys, and the federation's relays. The organization details below are kept
|
reviews your organization details and issues a credential block
|
||||||
privately by the MA for the membership contract — they are never published and never travel on the
|
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
|
||||||
wire.</p>
|
binds your node's keys to the identifier. The public registry publishes only your identifier,
|
||||||
|
keys, and the federation's relays. The organization details below are kept privately by
|
||||||
|
the MA for the membership contract — never published, never on the wire.</p>
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<label>Short name — this becomes your identifier<br>
|
<label>Short name — this becomes your identifier<br>
|
||||||
<input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label>
|
<input name="label" id="label" required pattern="[A-Za-z0-9 -]+" placeholder="keswick-research"></label>
|
||||||
<p class="muted">identifier: <code id="preview">(type a short name)</code> — no domain or DNS of your own is needed.</p>
|
<p class="muted">identifier: <code id="preview">(type a short name)</code> — no domain or DNS of your own is needed.</p>
|
||||||
<label>Signup code<br>
|
|
||||||
<input name="code" type="password" placeholder="invite code"></label>
|
|
||||||
<p class="muted">The code is the admission gate: members are approved, not anonymous. Ask the MA
|
|
||||||
operator for one. (If you run the MA, it is the <code>--signup-code</code> passed to
|
|
||||||
<code>frxd registry serve</code>.)</p>
|
|
||||||
<label>Legal organization name<br>
|
<label>Legal organization name<br>
|
||||||
<input name="org" required placeholder="Keswick Research LLC"></label>
|
<input name="org" required placeholder="Keswick Research LLC"></label>
|
||||||
<label>Representative (authorized contact person)<br>
|
<label>Representative (authorized contact person)<br>
|
||||||
@@ -840,11 +847,6 @@ operator for one. (If you run the MA, it is the <code>--signup-code</code> passe
|
|||||||
<input name="address" placeholder="street, city, country"></label>
|
<input name="address" placeholder="street, city, country"></label>
|
||||||
<label>Organization domain (optional)<br>
|
<label>Organization domain (optional)<br>
|
||||||
<input name="domain" placeholder="example.org"></label>
|
<input name="domain" placeholder="example.org"></label>
|
||||||
<label>What you will share<br>
|
|
||||||
<select name="member_class">
|
|
||||||
<option value="source">Source member — content I own or host</option>
|
|
||||||
<option value="enrichment">Enrichment member — derived corpora (metadata-only)</option>
|
|
||||||
</select></label>
|
|
||||||
<label>Payment details (billing / payout — e.g. IBAN or payment handle)<br>
|
<label>Payment details (billing / payout — e.g. IBAN or payment handle)<br>
|
||||||
<input name="payment" placeholder="kept private; the protocol itself carries no payment"></label>
|
<input name="payment" placeholder="kept private; the protocol itself carries no payment"></label>
|
||||||
<label>Your privacy statement URL (optional)<br>
|
<label>Your privacy statement URL (optional)<br>
|
||||||
@@ -859,10 +861,10 @@ operator for one. (If you run the MA, it is the <code>--signup-code</code> passe
|
|||||||
<h2>2. Download</h2>
|
<h2>2. Download</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
|
<p>Static Linux x86_64 binaries (musl — no runtime dependencies):</p>
|
||||||
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64
|
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/frxd-linux-amd64
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frxd-linux-amd64.sha256
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/frxd-linux-amd64.sha256
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frx-linux-amd64
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/frx-linux-amd64
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.1/frx-linux-amd64.sha256</pre>
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.6/frx-linux-amd64.sha256</pre>
|
||||||
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
<p class="muted">All releases: <a href="https://git.federatedsearch.org/frx/frxd/releases">git.federatedsearch.org/frx/frxd/releases</a>.
|
||||||
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
Source and spec (<code>rfc.txt</code>): <a href="https://git.federatedsearch.org/frx/frxd">git.federatedsearch.org/frx/frxd</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -886,6 +888,9 @@ against the pinned MA key, and wires the federation relays — no domains, DNS,
|
|||||||
needed on your side.</li>
|
needed on your side.</li>
|
||||||
<li>Index a directory and mark what you share:</li>
|
<li>Index a directory and mark what you share:</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<p class="muted">The credential block is reusable: run the wizard on every node you operate —
|
||||||
|
each node binds its own key to your identifier. If the token leaks, the MA revokes it and
|
||||||
|
issues a fresh one.</p>
|
||||||
<pre class="cmd">frxd add ~/documents --name docs --shared --exposure metadata
|
<pre class="cmd">frxd add ~/documents --name docs --shared --exposure metadata
|
||||||
frxd serve</pre>
|
frxd serve</pre>
|
||||||
<p class="muted">Search local-first with <code>frx search "..."</code>; broadcast to the federation with
|
<p class="muted">Search local-first with <code>frx search "..."</code>; broadcast to the federation with
|
||||||
@@ -922,18 +927,20 @@ f.onsubmit = async (e) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"content-type": "application/json"},
|
headers: {"content-type": "application/json"},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label: f.label.value, code: f.code.value,
|
label: f.label.value,
|
||||||
org: f.org.value, representative: f.representative.value, email: f.email.value,
|
org: f.org.value, representative: f.representative.value, email: f.email.value,
|
||||||
address: f.address.value, domain: f.domain.value, class: f.member_class.value,
|
address: f.address.value, domain: f.domain.value,
|
||||||
payment: f.payment.value, privacy_link: f.privacy_link.value,
|
payment: f.payment.value, privacy_link: f.privacy_link.value,
|
||||||
attestation: f.attestation.checked, privacy_ack: f.privacy_ack.checked
|
attestation: f.attestation.checked, privacy_ack: f.privacy_ack.checked
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
out.style.display = "block";
|
out.style.display = "block";
|
||||||
out.textContent = res.ok
|
out.textContent = body.credentials
|
||||||
? "Membership approved.\n\nNext: download frxd (step 2), install it (step 3), then run `frxd --onboarding` and paste this block:\n\n" + body.credentials + "\n"
|
? "Membership approved.\n\nNext: download frxd (step 2), install it (step 3), then run `frxd --onboarding` and paste this block:\n\n" + body.credentials + "\n"
|
||||||
: "Failed: " + (body.error || ("http " + res.status));
|
: res.ok
|
||||||
|
? "Application received.\n\n" + (body.message || "The membership authority will review it and issue your credential block.")
|
||||||
|
: "Failed: " + (body.error || ("http " + res.status));
|
||||||
};
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -56,15 +56,10 @@ pub struct NodeSection {
|
|||||||
pub responder: bool,
|
pub responder: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const CLASS_SOURCE: &str = "source";
|
|
||||||
pub const CLASS_ENRICHMENT: &str = "enrichment";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Member {
|
pub struct Member {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub pubkey: String,
|
pub pubkey: String,
|
||||||
#[serde(default = "default_class")]
|
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub previous: Vec<String>,
|
pub previous: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -130,10 +125,6 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_class() -> String {
|
|
||||||
CLASS_SOURCE.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_max_results() -> usize {
|
fn default_max_results() -> usize {
|
||||||
5
|
5
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-17
@@ -123,8 +123,6 @@ enum MemberCommand {
|
|||||||
Add {
|
Add {
|
||||||
name: String,
|
name: String,
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
#[arg(long, default_value = "source")]
|
|
||||||
class: String,
|
|
||||||
#[arg(long = "previous")]
|
#[arg(long = "previous")]
|
||||||
previous: Vec<String>,
|
previous: Vec<String>,
|
||||||
},
|
},
|
||||||
@@ -150,8 +148,6 @@ enum RegistryCommand {
|
|||||||
Add {
|
Add {
|
||||||
id: String,
|
id: String,
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
#[arg(long, default_value = "source")]
|
|
||||||
class: String,
|
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
not_before: Option<u64>,
|
not_before: Option<u64>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -180,6 +176,24 @@ enum RegistryCommand {
|
|||||||
},
|
},
|
||||||
List,
|
List,
|
||||||
Applications,
|
Applications,
|
||||||
|
Approve {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
registry_url: Option<String>,
|
||||||
|
},
|
||||||
|
Invite {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
registry_url: Option<String>,
|
||||||
|
},
|
||||||
|
Token {
|
||||||
|
id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
registry_url: Option<String>,
|
||||||
|
},
|
||||||
|
RevokeToken {
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
SetRelays {
|
SetRelays {
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
relays: Vec<String>,
|
relays: Vec<String>,
|
||||||
@@ -188,10 +202,6 @@ enum RegistryCommand {
|
|||||||
Serve {
|
Serve {
|
||||||
#[arg(long, default_value = "127.0.0.1:7800")]
|
#[arg(long, default_value = "127.0.0.1:7800")]
|
||||||
listen: String,
|
listen: String,
|
||||||
#[arg(long)]
|
|
||||||
signup_code: Option<String>,
|
|
||||||
#[arg(long)]
|
|
||||||
registry_url: Option<String>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +328,8 @@ async fn main() -> Result<()> {
|
|||||||
MemberCommand::Add {
|
MemberCommand::Add {
|
||||||
name,
|
name,
|
||||||
pubkey,
|
pubkey,
|
||||||
class,
|
|
||||||
previous,
|
previous,
|
||||||
} => commands::member_add(&cli.config, &name, &pubkey, &class, &previous)?,
|
} => commands::member_add(&cli.config, &name, &pubkey, &previous)?,
|
||||||
MemberCommand::Remove { name } => commands::member_remove(&cli.config, &name)?,
|
MemberCommand::Remove { name } => commands::member_remove(&cli.config, &name)?,
|
||||||
MemberCommand::List => commands::member_list(&cli.config)?,
|
MemberCommand::List => commands::member_list(&cli.config)?,
|
||||||
},
|
},
|
||||||
@@ -334,12 +343,11 @@ async fn main() -> Result<()> {
|
|||||||
RegistryCommand::Add {
|
RegistryCommand::Add {
|
||||||
id,
|
id,
|
||||||
pubkey,
|
pubkey,
|
||||||
class,
|
|
||||||
not_before,
|
not_before,
|
||||||
not_after,
|
not_after,
|
||||||
enc_key,
|
enc_key,
|
||||||
} => {
|
} => {
|
||||||
commands::registry_add(&dir, &id, &pubkey, &class, not_before, not_after, enc_key)?
|
commands::registry_add(&dir, &id, &pubkey, not_before, not_after, enc_key)?
|
||||||
}
|
}
|
||||||
RegistryCommand::SetEncKey { id, enc_key } => {
|
RegistryCommand::SetEncKey { id, enc_key } => {
|
||||||
commands::registry_set_enc_key(&dir, &id, &enc_key)?
|
commands::registry_set_enc_key(&dir, &id, &enc_key)?
|
||||||
@@ -356,13 +364,19 @@ async fn main() -> Result<()> {
|
|||||||
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
|
RegistryCommand::Remove { id } => commands::registry_remove(&dir, &id)?,
|
||||||
RegistryCommand::List => commands::registry_list(&dir)?,
|
RegistryCommand::List => commands::registry_list(&dir)?,
|
||||||
RegistryCommand::Applications => commands::registry_applications(&dir)?,
|
RegistryCommand::Applications => commands::registry_applications(&dir)?,
|
||||||
|
RegistryCommand::Approve { id, registry_url } => {
|
||||||
|
commands::registry_approve(&dir, &id, registry_url.as_deref())?
|
||||||
|
}
|
||||||
|
RegistryCommand::Invite { id, registry_url } => {
|
||||||
|
commands::registry_invite(&dir, &id, registry_url.as_deref())?
|
||||||
|
}
|
||||||
|
RegistryCommand::Token { id, registry_url } => {
|
||||||
|
commands::registry_token(&dir, &id, registry_url.as_deref())?;
|
||||||
|
}
|
||||||
|
RegistryCommand::RevokeToken { id } => commands::registry_revoke_token(&dir, &id)?,
|
||||||
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
|
RegistryCommand::SetRelays { relays } => commands::registry_set_relays(&dir, &relays)?,
|
||||||
RegistryCommand::Show => commands::registry_show(&dir)?,
|
RegistryCommand::Show => commands::registry_show(&dir)?,
|
||||||
RegistryCommand::Serve {
|
RegistryCommand::Serve { listen } => commands::registry_serve(&dir, &listen).await?,
|
||||||
listen,
|
|
||||||
signup_code,
|
|
||||||
registry_url,
|
|
||||||
} => commands::registry_serve(&dir, &listen, signup_code, registry_url).await?,
|
|
||||||
},
|
},
|
||||||
Command::Aggregates {
|
Command::Aggregates {
|
||||||
from,
|
from,
|
||||||
|
|||||||
+9
-19
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::config::{CLASS_ENRICHMENT, Config, Member, load_members};
|
use crate::config::{Config, Member, load_members};
|
||||||
use crate::crypto::{Keypair, now_ts, poll_signing_bytes};
|
use crate::crypto::{Keypair, now_ts, poll_signing_bytes};
|
||||||
use crate::engine::{SearchEngine, TantivyEngine};
|
use crate::engine::{SearchEngine, TantivyEngine};
|
||||||
use crate::index::{LocalIndex, SearchHit, response_items};
|
use crate::index::{LocalIndex, SearchHit, response_items};
|
||||||
@@ -193,13 +193,10 @@ impl Node {
|
|||||||
*self.members_mtime.lock().expect("members mtime lock") = mtime;
|
*self.members_mtime.lock().expect("members mtime lock") = mtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member_class(&self, members: &[Member], pubkey: &str) -> Option<String> {
|
fn member_known(&self, members: &[Member], pubkey: &str) -> bool {
|
||||||
members
|
members
|
||||||
.iter()
|
.iter()
|
||||||
.find(|member| {
|
.any(|member| member.pubkey == pubkey || member.previous.iter().any(|key| key == pubkey))
|
||||||
member.pubkey == pubkey || member.previous.iter().any(|key| key == pubkey)
|
|
||||||
})
|
|
||||||
.map(|member| member.class.clone())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aggregate_for(&self, period: &str, member: Option<&str>) -> AggregateBody {
|
pub fn aggregate_for(&self, period: &str, member: Option<&str>) -> AggregateBody {
|
||||||
@@ -464,21 +461,19 @@ impl Node {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.refresh_members();
|
self.refresh_members();
|
||||||
let (class, listed) = if let Some(watcher) = &self.registry {
|
let listed = if let Some(watcher) = &self.registry {
|
||||||
watcher.refresh_if_changed();
|
watcher.refresh_if_changed();
|
||||||
match watcher.authorized(&envelope.key, now_ts()) {
|
match watcher.authorized(&envelope.key, now_ts()) {
|
||||||
Some((id, class)) if id == envelope.from => (Some(class), true),
|
Some(id) if id == envelope.from => true,
|
||||||
_ => (None, false),
|
_ => false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let members = self.members.read().expect("members lock");
|
let members = self.members.read().expect("members lock");
|
||||||
let class = self.member_class(&members, &envelope.key);
|
if members.is_empty() {
|
||||||
let listed = if members.is_empty() {
|
|
||||||
self.config.node.dev_bootstrap
|
self.config.node.dev_bootstrap
|
||||||
} else {
|
} else {
|
||||||
class.is_some() && envelope.from == envelope.key
|
self.member_known(&members, &envelope.key) && envelope.from == envelope.key
|
||||||
};
|
}
|
||||||
(class, listed)
|
|
||||||
};
|
};
|
||||||
if !listed {
|
if !listed {
|
||||||
return;
|
return;
|
||||||
@@ -520,11 +515,6 @@ impl Node {
|
|||||||
let Ok(body) = envelope.parse_body::<ResponseBody>() else {
|
let Ok(body) = envelope.parse_body::<ResponseBody>() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if class.as_deref() == Some(CLASS_ENRICHMENT)
|
|
||||||
&& body.results.iter().any(|result| result.content.is_some())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut pending = self.pending.lock().expect("pending lock");
|
let mut pending = self.pending.lock().expect("pending lock");
|
||||||
if let Some(list) = pending.get_mut(&body.qid) {
|
if let Some(list) = pending.get_mut(&body.qid) {
|
||||||
list.push((envelope.from.clone(), body));
|
list.push((envelope.from.clone(), body));
|
||||||
|
|||||||
+105
-12
@@ -6,6 +6,7 @@ use std::time::SystemTime;
|
|||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::Digest;
|
||||||
|
|
||||||
use crate::PROTOCOL;
|
use crate::PROTOCOL;
|
||||||
use crate::crypto::{Keypair, canonical_json, now_ts, verify_signature};
|
use crate::crypto::{Keypair, canonical_json, now_ts, verify_signature};
|
||||||
@@ -23,8 +24,6 @@ pub struct KeyEntry {
|
|||||||
pub struct RegistryMember {
|
pub struct RegistryMember {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub keys: Vec<KeyEntry>,
|
pub keys: Vec<KeyEntry>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enc_key: Option<String>,
|
pub enc_key: Option<String>,
|
||||||
@@ -124,14 +123,19 @@ pub struct Application {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub class: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub payment: String,
|
pub payment: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub privacy_link: String,
|
pub privacy_link: String,
|
||||||
|
/// "pending" until the MA approves, then "approved".
|
||||||
|
#[serde(default = "default_status")]
|
||||||
|
pub status: String,
|
||||||
pub submitted_at: u64,
|
pub submitted_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_status() -> String {
|
||||||
|
"pending".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn applications_path(dir: &Path) -> PathBuf {
|
pub fn applications_path(dir: &Path) -> PathBuf {
|
||||||
dir.join("applications.json")
|
dir.join("applications.json")
|
||||||
}
|
}
|
||||||
@@ -144,15 +148,106 @@ pub fn load_applications(path: &Path) -> Result<Vec<Application>> {
|
|||||||
serde_json::from_str(&raw).context("parsing applications")
|
serde_json::from_str(&raw).context("parsing applications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn save_applications(path: &Path, applications: &[Application]) -> Result<()> {
|
||||||
|
fs::write(path, serde_json::to_string_pretty(applications)?)?;
|
||||||
|
crate::config::set_private_permissions(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_application(dir: &Path, application: Application) -> Result<()> {
|
pub fn record_application(dir: &Path, application: Application) -> Result<()> {
|
||||||
let path = applications_path(dir);
|
let path = applications_path(dir);
|
||||||
let mut applications = load_applications(&path)?;
|
let mut applications = load_applications(&path)?;
|
||||||
applications.push(application);
|
applications.push(application);
|
||||||
fs::write(&path, serde_json::to_string_pretty(&applications)?)?;
|
save_applications(&path, &applications)
|
||||||
crate::config::set_private_permissions(&path)?;
|
}
|
||||||
|
|
||||||
|
/// Marks a pending application approved and returns it. Errors if unknown or not pending.
|
||||||
|
pub fn approve_application(dir: &Path, id: &str) -> Result<Application> {
|
||||||
|
let path = applications_path(dir);
|
||||||
|
let mut applications = load_applications(&path)?;
|
||||||
|
let Some(application) = applications.iter_mut().find(|app| app.id == id) else {
|
||||||
|
return Err(anyhow!("no application for {id}"));
|
||||||
|
};
|
||||||
|
if application.status != "pending" {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"application for {id} is not pending ({})",
|
||||||
|
application.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
application.status = "approved".to_string();
|
||||||
|
let approved = application.clone();
|
||||||
|
save_applications(&path, &applications)?;
|
||||||
|
Ok(approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Member token: the member's account credential at the MA. Authorizes key
|
||||||
|
/// enrollment for every node the member runs. Stored hashed; MA-private.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemberToken {
|
||||||
|
pub id: String,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub created: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tokens_path(dir: &Path) -> PathBuf {
|
||||||
|
dir.join("tokens.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_tokens(path: &Path) -> Result<Vec<MemberToken>> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let raw = fs::read_to_string(path).context("reading tokens")?;
|
||||||
|
serde_json::from_str(&raw).context("parsing tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_tokens(path: &Path, tokens: &[MemberToken]) -> Result<()> {
|
||||||
|
fs::write(path, serde_json::to_string_pretty(tokens)?)?;
|
||||||
|
crate::config::set_private_permissions(path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hash_token(token: &str) -> String {
|
||||||
|
hex::encode(sha2::Sha256::digest(token.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mints a fresh member token and returns it (only the hash is stored).
|
||||||
|
pub fn create_token(dir: &Path, id: &str) -> Result<String> {
|
||||||
|
let raw = crate::crypto::random_nonce();
|
||||||
|
let path = tokens_path(dir);
|
||||||
|
let mut tokens = load_tokens(&path)?;
|
||||||
|
tokens.push(MemberToken {
|
||||||
|
id: id.to_string(),
|
||||||
|
token_hash: hash_token(&raw),
|
||||||
|
created: now_ts(),
|
||||||
|
revoked: false,
|
||||||
|
});
|
||||||
|
save_tokens(&path, &tokens)?;
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_token(dir: &Path, id: &str, token: &str) -> Result<bool> {
|
||||||
|
let hash = hash_token(token);
|
||||||
|
Ok(load_tokens(&tokens_path(dir))?
|
||||||
|
.iter()
|
||||||
|
.any(|entry| entry.id == id && !entry.revoked && entry.token_hash == hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes all tokens for a member; returns how many were active.
|
||||||
|
pub fn revoke_tokens(dir: &Path, id: &str) -> Result<usize> {
|
||||||
|
let path = tokens_path(dir);
|
||||||
|
let mut tokens = load_tokens(&path)?;
|
||||||
|
let mut revoked = 0;
|
||||||
|
for entry in tokens.iter_mut().filter(|entry| entry.id == id && !entry.revoked) {
|
||||||
|
entry.revoked = true;
|
||||||
|
revoked += 1;
|
||||||
|
}
|
||||||
|
save_tokens(&path, &tokens)?;
|
||||||
|
Ok(revoked)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SignedRegistry {
|
pub struct SignedRegistry {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -287,7 +382,7 @@ impl Watcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorized(&self, key: &str, now: u64) -> Option<(String, String)> {
|
pub fn authorized(&self, key: &str, now: u64) -> Option<String> {
|
||||||
let current = self.current.lock().expect("registry lock");
|
let current = self.current.lock().expect("registry lock");
|
||||||
current
|
current
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -297,7 +392,7 @@ impl Watcher {
|
|||||||
pub fn enc_key(&self, member_key: &str) -> Option<String> {
|
pub fn enc_key(&self, member_key: &str) -> Option<String> {
|
||||||
let current = self.current.lock().expect("registry lock");
|
let current = self.current.lock().expect("registry lock");
|
||||||
let signed = current.as_ref()?;
|
let signed = current.as_ref()?;
|
||||||
let (id, _) = authorized_keys(signed, now_ts()).remove(member_key)?;
|
let id = authorized_keys(signed, now_ts()).remove(member_key)?;
|
||||||
signed
|
signed
|
||||||
.doc
|
.doc
|
||||||
.members
|
.members
|
||||||
@@ -324,7 +419,7 @@ impl Watcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, (String, String)> {
|
pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, String> {
|
||||||
let mut authorized = HashMap::new();
|
let mut authorized = HashMap::new();
|
||||||
for member in &signed.doc.members {
|
for member in &signed.doc.members {
|
||||||
for key in &member.keys {
|
for key in &member.keys {
|
||||||
@@ -332,7 +427,7 @@ pub fn authorized_keys(signed: &SignedRegistry, now: u64) -> HashMap<String, (St
|
|||||||
if valid {
|
if valid {
|
||||||
authorized
|
authorized
|
||||||
.entry(key.key.clone())
|
.entry(key.key.clone())
|
||||||
.or_insert((member.id.clone(), member.class.clone()));
|
.or_insert(member.id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +447,6 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, key_hex, not_before, not_after)| RegistryMember {
|
.map(|(id, key_hex, not_before, not_after)| RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key_hex.to_string(),
|
key: key_hex.to_string(),
|
||||||
not_before,
|
not_before,
|
||||||
@@ -412,7 +506,6 @@ mod tests {
|
|||||||
let mut expired = registry.doc.members.clone();
|
let mut expired = registry.doc.members.clone();
|
||||||
expired.push(RegistryMember {
|
expired.push(RegistryMember {
|
||||||
id: "expired".to_string(),
|
id: "expired".to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: Keypair::generate().public_hex(),
|
key: Keypair::generate().public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
|
|||||||
+2
-73
@@ -4,7 +4,7 @@ use std::fs;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use common::{ask, client, collection, config_for, spawn_relay};
|
use common::{ask, client, collection, config_for, spawn_relay};
|
||||||
use frxd::config::{CLASS_ENRICHMENT, CLASS_SOURCE, Member, save_members};
|
use frxd::config::{Member, save_members};
|
||||||
use frxd::index::LocalIndex;
|
use frxd::index::LocalIndex;
|
||||||
use frxd::message::EXPOSURE_FULL;
|
use frxd::message::EXPOSURE_FULL;
|
||||||
use frxd::node::{self, Node, NodeHandle, current_period};
|
use frxd::node::{self, Node, NodeHandle, current_period};
|
||||||
@@ -163,77 +163,7 @@ async fn aggregate_floor_rejects_finer_than_month() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn enrichment_members_cannot_send_content() {
|
async fn full_exposure_responses_carry_content() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let relay_url = spawn_relay().await;
|
|
||||||
let bob_full = start_node(
|
|
||||||
root.path(),
|
|
||||||
"bob",
|
|
||||||
&relay_url,
|
|
||||||
EXPOSURE_FULL,
|
|
||||||
&[("doc.txt", "enrichment test rust content")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let carol_meta = start_node(
|
|
||||||
root.path(),
|
|
||||||
"carol",
|
|
||||||
&relay_url,
|
|
||||||
"metadata",
|
|
||||||
&[("doc.txt", "enrichment test rust metadata")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let alice = start_node(
|
|
||||||
root.path(),
|
|
||||||
"alice",
|
|
||||||
&relay_url,
|
|
||||||
EXPOSURE_FULL,
|
|
||||||
&[("mine.txt", "alice local")],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
save_members(
|
|
||||||
&alice.node.config.members_path(),
|
|
||||||
&[
|
|
||||||
Member {
|
|
||||||
name: "bob".to_string(),
|
|
||||||
pubkey: bob_full.pubkey.clone(),
|
|
||||||
class: CLASS_ENRICHMENT.to_string(),
|
|
||||||
previous: Vec::new(),
|
|
||||||
},
|
|
||||||
Member {
|
|
||||||
name: "carol".to_string(),
|
|
||||||
pubkey: carol_meta.pubkey.clone(),
|
|
||||||
class: CLASS_ENRICHMENT.to_string(),
|
|
||||||
previous: Vec::new(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
|
|
||||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
responses.len(),
|
|
||||||
1,
|
|
||||||
"full-exposure enrichment reply must be dropped"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
responses[0].get("member").and_then(Value::as_str),
|
|
||||||
Some(carol_meta.pubkey.as_str())
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
responses[0]
|
|
||||||
.get("results")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.unwrap()[0]
|
|
||||||
.get("content")
|
|
||||||
.unwrap()
|
|
||||||
.is_null()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
async fn source_members_may_send_content() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let relay_url = spawn_relay().await;
|
let relay_url = spawn_relay().await;
|
||||||
let bob = start_node(
|
let bob = start_node(
|
||||||
@@ -257,7 +187,6 @@ async fn source_members_may_send_content() {
|
|||||||
&[Member {
|
&[Member {
|
||||||
name: "bob".to_string(),
|
name: "bob".to_string(),
|
||||||
pubkey: bob.pubkey.clone(),
|
pubkey: bob.pubkey.clone(),
|
||||||
class: CLASS_SOURCE.to_string(),
|
|
||||||
previous: Vec::new(),
|
previous: Vec::new(),
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-4
@@ -132,8 +132,6 @@ fn cli_init_add_search_status() {
|
|||||||
"add".to_string(),
|
"add".to_string(),
|
||||||
"carol".to_string(),
|
"carol".to_string(),
|
||||||
"ab".repeat(32),
|
"ab".repeat(32),
|
||||||
"--class".to_string(),
|
|
||||||
"enrichment".to_string(),
|
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("carol"));
|
assert!(stdout.contains("carol"));
|
||||||
let stdout = run_ok(&[
|
let stdout = run_ok(&[
|
||||||
@@ -142,7 +140,7 @@ fn cli_init_add_search_status() {
|
|||||||
"member".to_string(),
|
"member".to_string(),
|
||||||
"list".to_string(),
|
"list".to_string(),
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("carol [enrichment]"));
|
assert!(stdout.contains("carol"));
|
||||||
|
|
||||||
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
|
let stdout = run_ok(&add_args(&config, &docs, "docs", true));
|
||||||
assert!(stdout.contains("indexed 1 file(s)"));
|
assert!(stdout.contains("indexed 1 file(s)"));
|
||||||
@@ -252,7 +250,7 @@ fn cli_registry_lifecycle() {
|
|||||||
dir_arg.clone(),
|
dir_arg.clone(),
|
||||||
"list".to_string(),
|
"list".to_string(),
|
||||||
]);
|
]);
|
||||||
assert!(stdout.contains("alice.frx.example [source] (2 key(s))"));
|
assert!(stdout.contains("alice.frx.example (2 key(s))"));
|
||||||
|
|
||||||
let stdout = run_ok(&[
|
let stdout = run_ok(&[
|
||||||
"registry".to_string(),
|
"registry".to_string(),
|
||||||
|
|||||||
@@ -367,7 +367,6 @@ async fn member_directory_filters_senders() {
|
|||||||
&[frxd::config::Member {
|
&[frxd::config::Member {
|
||||||
name: "alice".to_string(),
|
name: "alice".to_string(),
|
||||||
pubkey: alice.public_hex(),
|
pubkey: alice.public_hex(),
|
||||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
|
||||||
previous: Vec::new(),
|
previous: Vec::new(),
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
@@ -409,7 +408,6 @@ async fn rotated_keys_are_accepted_through_previous_listing() {
|
|||||||
vec![frxd::config::Member {
|
vec![frxd::config::Member {
|
||||||
name: "carol".to_string(),
|
name: "carol".to_string(),
|
||||||
pubkey: new_key.public_hex(),
|
pubkey: new_key.public_hex(),
|
||||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
|
||||||
previous,
|
previous,
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ use serde_json::Value;
|
|||||||
fn member_with_enc(id: &str, ed: &Keypair, enc_public: &str) -> RegistryMember {
|
fn member_with_enc(id: &str, ed: &Keypair, enc_public: &str) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: "source".to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: ed.public_hex(),
|
key: ed.public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
|
|||||||
+4
-5
@@ -106,10 +106,9 @@ fn registry_doc(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn member(id: &str, key: &Keypair, class: &str) -> RegistryMember {
|
fn member(id: &str, key: &Keypair) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key.public_hex(),
|
key: key.public_hex(),
|
||||||
not_before: 0,
|
not_before: 0,
|
||||||
@@ -129,7 +128,7 @@ async fn relay_admission_gates_unlisted_keys() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
®istry_doc(
|
®istry_doc(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member("alice.frx.example", &alice, "source")],
|
vec![member("alice.frx.example", &alice)],
|
||||||
vec![],
|
vec![],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
@@ -187,8 +186,8 @@ async fn node_discovers_relays_from_registry() {
|
|||||||
®istry_doc(
|
®istry_doc(
|
||||||
&ma,
|
&ma,
|
||||||
vec![
|
vec![
|
||||||
member("alice.frx.example", &alice, "source"),
|
member("alice.frx.example", &alice),
|
||||||
member("bob.frx.example", &bob_config.load_key().unwrap(), "source"),
|
member("bob.frx.example", &bob_config.load_key().unwrap()),
|
||||||
],
|
],
|
||||||
vec![relay_url.clone()],
|
vec![relay_url.clone()],
|
||||||
1,
|
1,
|
||||||
|
|||||||
+194
-98
@@ -9,15 +9,11 @@ use frxd::registry::{self};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
async fn spawn_registry_server(dir: &Path, signup_code: Option<&str>) -> String {
|
async fn spawn_registry_server(dir: &Path) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let base = format!("http://{addr}");
|
let base = format!("http://{addr}");
|
||||||
let router = commands::registry_router(
|
let router = commands::registry_router(dir);
|
||||||
dir,
|
|
||||||
signup_code.map(str::to_string),
|
|
||||||
Some(format!("{base}/registry.json")),
|
|
||||||
);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = axum::serve(listener, router).await;
|
let _ = axum::serve(listener, router).await;
|
||||||
});
|
});
|
||||||
@@ -30,88 +26,132 @@ fn setup_ma(root: &Path) -> std::path::PathBuf {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
fn full_application(label: &str) -> Value {
|
||||||
async fn signup_issues_invite_and_enroll_binds_key() {
|
serde_json::json!({
|
||||||
let root = tempfile::tempdir().unwrap();
|
"label": label,
|
||||||
let dir = setup_ma(root.path());
|
"org": format!("{label} Org"),
|
||||||
let base = spawn_registry_server(&dir, Some("sesame")).await;
|
"representative": "R. Ep",
|
||||||
let http = reqwest::Client::builder()
|
"email": "ops@example.org",
|
||||||
.timeout(Duration::from_secs(5))
|
"attestation": true,
|
||||||
.build()
|
"privacy_ack": true
|
||||||
.unwrap();
|
})
|
||||||
|
}
|
||||||
let rejected = http
|
|
||||||
.post(format!("{base}/v1/signup"))
|
|
||||||
.json(&serde_json::json!({"label": "alice", "code": "wrong"}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
|
|
||||||
|
|
||||||
|
async fn submit_application(http: &reqwest::Client, base: &str, label: &str) -> Value {
|
||||||
let response = http
|
let response = http
|
||||||
.post(format!("{base}/v1/signup"))
|
.post(format!("{base}/v1/signup"))
|
||||||
.json(&serde_json::json!({"label": "Alice Dev", "code": "sesame", "attestation": true, "privacy_ack": true}))
|
.json(&full_application(label))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(response.status().is_success());
|
assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
let body: Value = response.json().await.unwrap();
|
response.json().await.unwrap()
|
||||||
let id = body.get("id").and_then(Value::as_str).unwrap().to_string();
|
}
|
||||||
assert_eq!(id, "alice-dev.frx.invalid");
|
|
||||||
let token = body
|
fn invite_token(dir: &Path, id: &str) -> String {
|
||||||
.get("token")
|
registry::load_invites(&dir.join("invites.json"))
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_string();
|
.into_iter()
|
||||||
|
.rev()
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
.find(|invite| invite.id == id)
|
||||||
assert!(
|
.unwrap()
|
||||||
registry::authorized_keys(&signed, now_ts()).is_empty(),
|
.token
|
||||||
"signup must not authorize a key before enrollment"
|
|
||||||
);
|
|
||||||
|
|
||||||
let key = Keypair::generate();
|
|
||||||
let response = http
|
|
||||||
.post(format!("{base}/v1/enroll"))
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"token": token,
|
|
||||||
"pubkey": key.public_hex(),
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(response.status().is_success());
|
|
||||||
|
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
|
||||||
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
|
|
||||||
|
|
||||||
let replayed = http
|
|
||||||
.post(format!("{base}/v1/enroll"))
|
|
||||||
.json(&serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"token": token,
|
|
||||||
"pubkey": Keypair::generate().public_hex(),
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn signup_stores_private_application_and_class() {
|
async fn application_pending_then_approve_then_enroll_binds_key() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let dir = setup_ma(root.path());
|
let dir = setup_ma(root.path());
|
||||||
let base = spawn_registry_server(&dir, Some("sesame")).await;
|
let base = spawn_registry_server(&dir).await;
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
let body = submit_application(&http, &base, "Alice Dev").await;
|
||||||
|
let id = "alice-dev.frx.invalid";
|
||||||
|
assert_eq!(body.get("status").and_then(Value::as_str), Some("pending"));
|
||||||
|
assert_eq!(body.get("id").and_then(Value::as_str), Some(id));
|
||||||
|
assert!(body.get("credentials").is_none());
|
||||||
|
|
||||||
|
// pending: no member entry yet, application on file
|
||||||
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
|
assert!(signed.doc.members.iter().all(|member| member.id != id));
|
||||||
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
|
assert_eq!(apps.len(), 1);
|
||||||
|
assert_eq!(apps[0].status, "pending");
|
||||||
|
|
||||||
|
// a duplicate application for the same identifier is rejected
|
||||||
|
let dup = http
|
||||||
|
.post(format!("{base}/v1/signup"))
|
||||||
|
.json(&full_application("Alice Dev"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
// MA approves: member stub; a member token then authorizes key enrollment
|
||||||
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
|
assert!(signed.doc.members.iter().any(|member| member.id == id));
|
||||||
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
|
assert_eq!(apps[0].status, "approved");
|
||||||
|
|
||||||
|
let enroll = |token: &str, pubkey: String| {
|
||||||
|
let http = http.clone();
|
||||||
|
let base = base.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
let token = token.to_string();
|
||||||
|
async move {
|
||||||
|
http.post(format!("{base}/v1/enroll"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"id": id, "token": token, "pubkey": pubkey,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// the account credential is reusable: two nodes, two keys, one token
|
||||||
|
let token = commands::registry_token(&dir, id, None).unwrap();
|
||||||
|
let key1 = Keypair::generate();
|
||||||
|
let key2 = Keypair::generate();
|
||||||
|
assert!(enroll(&token, key1.public_hex()).await.is_success());
|
||||||
|
assert!(enroll(&token, key2.public_hex()).await.is_success());
|
||||||
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
|
let authorized = registry::authorized_keys(&signed, now_ts());
|
||||||
|
assert!(authorized.contains_key(&key1.public_hex()));
|
||||||
|
assert!(authorized.contains_key(&key2.public_hex()));
|
||||||
|
|
||||||
|
// after revocation the token no longer enrolls; a fresh one does
|
||||||
|
commands::registry_revoke_token(&dir, id).unwrap();
|
||||||
|
let key3 = Keypair::generate();
|
||||||
|
assert_eq!(
|
||||||
|
enroll(&token, key3.public_hex()).await,
|
||||||
|
reqwest::StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
let fresh = commands::registry_token(&dir, id, None).unwrap();
|
||||||
|
assert!(enroll(&fresh, key3.public_hex()).await.is_success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn signup_stores_private_application() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let dir = setup_ma(root.path());
|
||||||
|
let base = spawn_registry_server(&dir).await;
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// missing acknowledgements are rejected
|
||||||
let missing = http
|
let missing = http
|
||||||
.post(format!("{base}/v1/signup"))
|
.post(format!("{base}/v1/signup"))
|
||||||
.json(&serde_json::json!({"label": "acme", "code": "sesame"}))
|
.json(&serde_json::json!({
|
||||||
|
"label": "acme", "org": "Acme", "representative": "A", "email": "a@acme.example"
|
||||||
|
}))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -121,13 +161,11 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
.post(format!("{base}/v1/signup"))
|
.post(format!("{base}/v1/signup"))
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"label": "Keswick Research",
|
"label": "Keswick Research",
|
||||||
"code": "sesame",
|
|
||||||
"org": "Keswick Research LLC",
|
"org": "Keswick Research LLC",
|
||||||
"representative": "J. Keswick",
|
"representative": "J. Keswick",
|
||||||
"email": "ops@keswick.example",
|
"email": "ops@keswick.example",
|
||||||
"address": "1 Fell Road, Keswick",
|
"address": "1 Fell Road, Keswick",
|
||||||
"domain": "keswick.example",
|
"domain": "keswick.example",
|
||||||
"class": "enrichment",
|
|
||||||
"payment": "IBAN XX00 0000",
|
"payment": "IBAN XX00 0000",
|
||||||
"privacy_link": "https://keswick.example/privacy",
|
"privacy_link": "https://keswick.example/privacy",
|
||||||
"attestation": true,
|
"attestation": true,
|
||||||
@@ -136,26 +174,13 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(response.status().is_success());
|
assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED);
|
||||||
let body: Value = response.json().await.unwrap();
|
let body: Value = response.json().await.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
body.get("id").and_then(Value::as_str),
|
body.get("id").and_then(Value::as_str),
|
||||||
Some("keswick-research.frx.invalid")
|
Some("keswick-research.frx.invalid")
|
||||||
);
|
);
|
||||||
|
|
||||||
// public registry stays minimal: identifier + class only, no org data
|
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
|
||||||
let member = signed
|
|
||||||
.doc
|
|
||||||
.members
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.id == "keswick-research.frx.invalid")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(member.class, frxd::config::CLASS_ENRICHMENT);
|
|
||||||
let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
|
|
||||||
assert!(!raw.contains("Keswick Research LLC"));
|
|
||||||
assert!(!raw.contains("ops@keswick.example"));
|
|
||||||
|
|
||||||
// private application record holds the contract details
|
// private application record holds the contract details
|
||||||
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
assert_eq!(apps.len(), 1);
|
assert_eq!(apps.len(), 1);
|
||||||
@@ -166,25 +191,96 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
assert_eq!(app.email, "ops@keswick.example");
|
assert_eq!(app.email, "ops@keswick.example");
|
||||||
assert_eq!(app.payment, "IBAN XX00 0000");
|
assert_eq!(app.payment, "IBAN XX00 0000");
|
||||||
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
|
assert_eq!(app.privacy_link, "https://keswick.example/privacy");
|
||||||
assert_eq!(app.class, "enrichment");
|
assert_eq!(app.status, "pending");
|
||||||
|
|
||||||
|
// approval creates the member entry
|
||||||
|
commands::registry_approve(&dir, "keswick-research.frx.invalid", None).unwrap();
|
||||||
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
|
assert!(
|
||||||
|
signed
|
||||||
|
.doc
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.id == "keswick-research.frx.invalid")
|
||||||
|
);
|
||||||
|
|
||||||
|
// public registry stays minimal: no org data in the signed snapshot
|
||||||
|
let raw = std::fs::read_to_string(dir.join("registry.json")).unwrap();
|
||||||
|
assert!(!raw.contains("Keswick Research LLC"));
|
||||||
|
assert!(!raw.contains("ops@keswick.example"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn invite_reissues_token_per_node() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let dir = setup_ma(root.path());
|
||||||
|
let base = spawn_registry_server(&dir).await;
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
submit_application(&http, &base, "Multi Node").await;
|
||||||
|
let id = "multi-node.frx.invalid";
|
||||||
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
|
|
||||||
|
let enroll = |token: String, pubkey: String| {
|
||||||
|
let http = http.clone();
|
||||||
|
let base = base.clone();
|
||||||
|
let id = id.to_string();
|
||||||
|
async move {
|
||||||
|
http.post(format!("{base}/v1/enroll"))
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"id": id, "token": token, "pubkey": pubkey,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// node 1: a single-use handoff invite
|
||||||
|
commands::registry_invite(&dir, id, None).unwrap();
|
||||||
|
let key1 = Keypair::generate();
|
||||||
|
let token1 = invite_token(&dir, id);
|
||||||
|
assert!(enroll(token1.clone(), key1.public_hex()).await.is_success());
|
||||||
|
|
||||||
|
// the invite is single-use: replay is rejected
|
||||||
|
assert_eq!(
|
||||||
|
enroll(token1, Keypair::generate().public_hex()).await,
|
||||||
|
reqwest::StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
|
||||||
|
// node 2: a fresh token from `registry invite`
|
||||||
|
commands::registry_invite(&dir, id, None).unwrap();
|
||||||
|
let key2 = Keypair::generate();
|
||||||
|
let token2 = invite_token(&dir, id);
|
||||||
|
assert!(enroll(token2, key2.public_hex()).await.is_success());
|
||||||
|
|
||||||
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
|
let authorized = registry::authorized_keys(&signed, now_ts());
|
||||||
|
assert!(authorized.contains_key(&key1.public_hex()));
|
||||||
|
assert!(authorized.contains_key(&key2.public_hex()));
|
||||||
|
|
||||||
|
// an invite for an unknown member fails
|
||||||
|
assert!(commands::registry_invite(&dir, "ghost.frx.invalid", None).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
async fn wizard_enrolls_and_writes_config() {
|
async fn wizard_enrolls_and_writes_config() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let dir = setup_ma(root.path());
|
let dir = setup_ma(root.path());
|
||||||
let base = spawn_registry_server(&dir, Some("sesame")).await;
|
let base = spawn_registry_server(&dir).await;
|
||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
let body: Value = http
|
submit_application(&http, &base, "Wizard Test").await;
|
||||||
.post(format!("{base}/v1/signup"))
|
let id = "wizard-test.frx.invalid";
|
||||||
.json(&serde_json::json!({"label": "Wizard Test", "code": "sesame", "attestation": true, "privacy_ack": true}))
|
commands::registry_approve(&dir, id, None).unwrap();
|
||||||
.send()
|
let token = commands::registry_token(&dir, id, None).unwrap();
|
||||||
.await
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
.unwrap()
|
let credentials = format!(
|
||||||
.json()
|
"id={id} token={token} registry={base}/registry.json ma_key={}",
|
||||||
.await
|
signed.doc.ma_key
|
||||||
.unwrap();
|
);
|
||||||
let credentials = body.get("credentials").and_then(Value::as_str).unwrap();
|
|
||||||
|
|
||||||
let config_path = root.path().join("wizard.toml");
|
let config_path = root.path().join("wizard.toml");
|
||||||
let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display());
|
let input = format!("{}\n{credentials}\n\n\n\nn\n", config_path.display());
|
||||||
@@ -198,7 +294,7 @@ async fn wizard_enrolls_and_writes_config() {
|
|||||||
assert!(text.contains("enrolled"), "{text}");
|
assert!(text.contains("enrolled"), "{text}");
|
||||||
|
|
||||||
let config = Config::load(&config_path).unwrap();
|
let config = Config::load(&config_path).unwrap();
|
||||||
assert_eq!(config.node.id.as_deref(), Some("wizard-test.frx.invalid"));
|
assert_eq!(config.node.id.as_deref(), Some(id));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.node.registry.as_deref(),
|
config.node.registry.as_deref(),
|
||||||
Some(format!("{base}/registry.json").as_str())
|
Some(format!("{base}/registry.json").as_str())
|
||||||
|
|||||||
+5
-8
@@ -19,13 +19,11 @@ use serde_json::Value;
|
|||||||
fn member_entry(
|
fn member_entry(
|
||||||
id: &str,
|
id: &str,
|
||||||
key: &Keypair,
|
key: &Keypair,
|
||||||
class: &str,
|
|
||||||
not_before: u64,
|
not_before: u64,
|
||||||
not_after: Option<u64>,
|
not_after: Option<u64>,
|
||||||
) -> RegistryMember {
|
) -> RegistryMember {
|
||||||
RegistryMember {
|
RegistryMember {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
class: class.to_string(),
|
|
||||||
keys: vec![KeyEntry {
|
keys: vec![KeyEntry {
|
||||||
key: key.public_hex(),
|
key: key.public_hex(),
|
||||||
not_before,
|
not_before,
|
||||||
@@ -122,7 +120,7 @@ async fn registry_gates_membership_and_revocation_propagates() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -162,7 +160,7 @@ async fn registry_gates_membership_and_revocation_propagates() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -187,7 +185,7 @@ async fn forged_or_wrong_key_snapshot_closes_the_registry() {
|
|||||||
®istry_path,
|
®istry_path,
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&attacker,
|
&attacker,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -222,7 +220,6 @@ async fn expired_key_is_not_authorized() {
|
|||||||
vec![member_entry(
|
vec![member_entry(
|
||||||
"alice.frx.example",
|
"alice.frx.example",
|
||||||
&alice,
|
&alice,
|
||||||
"source",
|
|
||||||
0,
|
0,
|
||||||
Some(now_ts().saturating_sub(1)),
|
Some(now_ts().saturating_sub(1)),
|
||||||
)],
|
)],
|
||||||
@@ -251,7 +248,7 @@ async fn fail_static_uses_last_validated_snapshot() {
|
|||||||
let alice = Keypair::generate();
|
let alice = Keypair::generate();
|
||||||
let snapshot = ma_registry(
|
let snapshot = ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -319,7 +316,7 @@ async fn registry_can_be_served_over_http() {
|
|||||||
®istry_dir.join("registry.json"),
|
®istry_dir.join("registry.json"),
|
||||||
&ma_registry(
|
&ma_registry(
|
||||||
&ma,
|
&ma,
|
||||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
vec![member_entry("alice.frx.example", &alice, 0, None)],
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user