Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276fe5e438 | ||
|
|
98ff17002f |
@@ -32,7 +32,7 @@
|
|||||||
- 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, 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).
|
||||||
- MA tooling: `frxd registry init|add|add-key|revoke-key|remove|list|applications|approve|invite|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)
|
||||||
@@ -50,6 +50,6 @@
|
|||||||
- 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 (`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 + invite + credential block), and `frxd registry invite <id>` mints another single-use 24h token — one per node the member runs. Invites live in `<registry dir>/invites.json`. 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); applicants never self-declare a class — the MA assigns it with `approve --class enrichment` (default source; classes are provenance, not roles — every member may query and respond, I5). `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).
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ frxd registry --dir ./ma init --zone frx.federatedsearch.org
|
|||||||
frxd registry --dir ./ma serve --listen 127.0.0.1:7800
|
frxd registry --dir ./ma serve --listen 127.0.0.1:7800
|
||||||
```
|
```
|
||||||
|
|
||||||
(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 and prints the credential block to hand over. Each node the member runs needs its own token: `frxd registry --dir <dir> invite <id>` mints another single-use 24h invite for the same identifier. 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.
|
(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 (`--class enrichment` at approval for derived-corpora members, which are metadata-only; the default `source` fits everyone else — membership itself has no roles or tiers). 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:
|
||||||
|
|
||||||
|
|||||||
+57
-34
@@ -449,15 +449,21 @@ pub fn registry_applications(dir: &Path) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approves a pending application: creates the member stub (class from the application),
|
/// Approves a pending application: creates the member stub, mints its account
|
||||||
/// issues a 24h invite, and prints the credential block to hand to the member.
|
/// credential (member token), and prints the credential block to hand over.
|
||||||
pub fn registry_approve(dir: &Path, id: &str, registry_url: Option<&str>) -> Result<()> {
|
/// `--class enrichment` is for derived corpora (metadata-only, §6); default is source.
|
||||||
|
pub fn registry_approve(
|
||||||
|
dir: &Path,
|
||||||
|
id: &str,
|
||||||
|
registry_url: Option<&str>,
|
||||||
|
class: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
if signed.doc.members.iter().any(|member| member.id == id) {
|
if signed.doc.members.iter().any(|member| member.id == id) {
|
||||||
return Err(anyhow!("member {id} already listed"));
|
return Err(anyhow!("member {id} already listed"));
|
||||||
}
|
}
|
||||||
let application = registry::approve_application(dir, id)?;
|
let _application = registry::approve_application(dir, id)?;
|
||||||
let class = if application.class == CLASS_ENRICHMENT {
|
let class = if class == Some(CLASS_ENRICHMENT) {
|
||||||
CLASS_ENRICHMENT
|
CLASS_ENRICHMENT
|
||||||
} else {
|
} else {
|
||||||
CLASS_SOURCE
|
CLASS_SOURCE
|
||||||
@@ -472,10 +478,11 @@ pub fn registry_approve(dir: &Path, id: &str, registry_url: Option<&str>) -> Res
|
|||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
let invite = registry::create_invite(dir, id, 24 * 3600)?;
|
let token = registry::create_token(dir, id)?;
|
||||||
let (_, signed) = open_registry(dir)?;
|
let (_, signed) = open_registry(dir)?;
|
||||||
println!("approved {id} ({class})");
|
println!("approved {id} ({class})");
|
||||||
print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
|
println!("member token — reusable for every node the member runs; keep private:");
|
||||||
|
print_credential_block(id, &token, registry_url, &signed.doc.ma_key);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,13 +494,33 @@ pub fn registry_invite(dir: &Path, id: &str, registry_url: Option<&str>) -> Resu
|
|||||||
return Err(anyhow!("no member named {id}"));
|
return Err(anyhow!("no member named {id}"));
|
||||||
}
|
}
|
||||||
let invite = registry::create_invite(dir, id, 24 * 3600)?;
|
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);
|
print_credential_block(id, &invite.token, registry_url, &signed.doc.ma_key);
|
||||||
Ok(())
|
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) {
|
fn print_credential_block(id: &str, token: &str, registry_url: Option<&str>, ma_key: &str) {
|
||||||
let registry_url = registry_url.unwrap_or("<registry-url>");
|
let registry_url = registry_url.unwrap_or("<registry-url>");
|
||||||
println!("hand this credential block to the member (single use, valid 24h):");
|
|
||||||
println!("id={id} token={token} registry={registry_url} ma_key={ma_key}");
|
println!("id={id} token={token} registry={registry_url} ma_key={ma_key}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,8 +616,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,
|
||||||
@@ -673,11 +698,6 @@ async fn registry_signup(
|
|||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
let class = if request.class.as_deref() == Some(CLASS_ENRICHMENT) {
|
|
||||||
CLASS_ENRICHMENT
|
|
||||||
} else {
|
|
||||||
CLASS_SOURCE
|
|
||||||
};
|
|
||||||
let application = registry::Application {
|
let application = registry::Application {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
org: request.org.clone(),
|
org: request.org.clone(),
|
||||||
@@ -685,7 +705,7 @@ 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(),
|
class: CLASS_SOURCE.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(),
|
status: "pending".to_string(),
|
||||||
@@ -741,12 +761,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
|
||||||
@@ -833,8 +857,9 @@ 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">This form requests membership from the membership authority (MA). The MA reviews
|
<p class="muted">This form requests membership from the membership authority (MA). Membership has no
|
||||||
your organization details and issues a credential block
|
roles or tiers: every member may broadcast queries and every member may answer them. The MA
|
||||||
|
reviews your organization details and issues a credential block
|
||||||
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
|
(<code>id=... token=... registry=... ma_key=...</code>); the onboarding wizard in step 4 then
|
||||||
binds your node's keys to the identifier. The public registry publishes only your identifier,
|
binds your node's keys to the identifier. The public registry publishes only your identifier,
|
||||||
class, keys, and the federation's relays. The organization details below are kept privately by
|
class, keys, and the federation's relays. The organization details below are kept privately by
|
||||||
@@ -853,11 +878,6 @@ the MA for the membership contract — never published, never on the wire.</p>
|
|||||||
<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>
|
||||||
@@ -872,10 +892,10 @@ the MA for the membership contract — never published, never on the wire.</p>
|
|||||||
<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.3/frxd-linux-amd64
|
<pre class="cmd">curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frxd-linux-amd64.sha256
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frxd-linux-amd64.sha256
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/frx-linux-amd64
|
||||||
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.3/frx-linux-amd64.sha256</pre>
|
curl -LO https://git.federatedsearch.org/frx/frxd/releases/download/v0.1.5/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>
|
||||||
@@ -899,6 +919,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
|
||||||
@@ -937,7 +960,7 @@ f.onsubmit = async (e) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label: f.label.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
|
||||||
})
|
})
|
||||||
|
|||||||
+19
-3
@@ -184,12 +184,22 @@ enum RegistryCommand {
|
|||||||
id: String,
|
id: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
registry_url: Option<String>,
|
registry_url: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
class: Option<String>,
|
||||||
},
|
},
|
||||||
Invite {
|
Invite {
|
||||||
id: String,
|
id: String,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
registry_url: Option<String>,
|
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>,
|
||||||
@@ -362,12 +372,18 @@ 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 } => {
|
RegistryCommand::Approve {
|
||||||
commands::registry_approve(&dir, &id, registry_url.as_deref())?
|
id,
|
||||||
}
|
registry_url,
|
||||||
|
class,
|
||||||
|
} => commands::registry_approve(&dir, &id, registry_url.as_deref(), class.as_deref())?,
|
||||||
RegistryCommand::Invite { id, registry_url } => {
|
RegistryCommand::Invite { id, registry_url } => {
|
||||||
commands::registry_invite(&dir, &id, registry_url.as_deref())?
|
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 { listen } => commands::registry_serve(&dir, &listen).await?,
|
RegistryCommand::Serve { listen } => commands::registry_serve(&dir, &listen).await?,
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -183,6 +184,74 @@ pub fn approve_application(dir: &Path, id: &str) -> Result<Application> {
|
|||||||
Ok(approved)
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|||||||
+61
-39
@@ -90,40 +90,50 @@ async fn application_pending_then_approve_then_enroll_binds_key() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
|
assert_eq!(dup.status(), reqwest::StatusCode::CONFLICT);
|
||||||
|
|
||||||
// MA approves: member stub + invite; enrollment binds the key
|
// MA approves: member stub; a member token then authorizes key enrollment
|
||||||
commands::registry_approve(&dir, id, None).unwrap();
|
commands::registry_approve(&dir, id, None, None).unwrap();
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
assert!(signed.doc.members.iter().any(|member| member.id == id));
|
assert!(signed.doc.members.iter().any(|member| member.id == id));
|
||||||
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
let apps = registry::load_applications(&dir.join("applications.json")).unwrap();
|
||||||
assert_eq!(apps[0].status, "approved");
|
assert_eq!(apps[0].status, "approved");
|
||||||
|
|
||||||
let key = Keypair::generate();
|
let enroll = |token: &str, pubkey: String| {
|
||||||
let enrolled = http
|
let http = http.clone();
|
||||||
.post(format!("{base}/v1/enroll"))
|
let base = base.clone();
|
||||||
.json(&serde_json::json!({
|
let id = id.to_string();
|
||||||
"id": id,
|
let token = token.to_string();
|
||||||
"token": invite_token(&dir, id),
|
async move {
|
||||||
"pubkey": key.public_hex(),
|
http.post(format!("{base}/v1/enroll"))
|
||||||
}))
|
.json(&serde_json::json!({
|
||||||
.send()
|
"id": id, "token": token, "pubkey": pubkey,
|
||||||
.await
|
}))
|
||||||
.unwrap();
|
.send()
|
||||||
assert!(enrolled.status().is_success());
|
.await
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
.unwrap()
|
||||||
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex()));
|
.status()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// the token is single-use
|
// the account credential is reusable: two nodes, two keys, one token
|
||||||
let replayed = http
|
let token = commands::registry_token(&dir, id, None).unwrap();
|
||||||
.post(format!("{base}/v1/enroll"))
|
let key1 = Keypair::generate();
|
||||||
.json(&serde_json::json!({
|
let key2 = Keypair::generate();
|
||||||
"id": id,
|
assert!(enroll(&token, key1.public_hex()).await.is_success());
|
||||||
"token": invite_token(&dir, id),
|
assert!(enroll(&token, key2.public_hex()).await.is_success());
|
||||||
"pubkey": Keypair::generate().public_hex(),
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
}))
|
let authorized = registry::authorized_keys(&signed, now_ts());
|
||||||
.send()
|
assert!(authorized.contains_key(&key1.public_hex()));
|
||||||
.await
|
assert!(authorized.contains_key(&key2.public_hex()));
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replayed.status(), reqwest::StatusCode::FORBIDDEN);
|
// 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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
@@ -156,7 +166,6 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
"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,
|
||||||
@@ -172,7 +181,8 @@ async fn signup_stores_private_application_and_class() {
|
|||||||
Some("keswick-research.frx.invalid")
|
Some("keswick-research.frx.invalid")
|
||||||
);
|
);
|
||||||
|
|
||||||
// private application record holds the contract details
|
// private application record holds the contract details; applicants do not
|
||||||
|
// self-declare a class — the MA assigns it at approval
|
||||||
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);
|
||||||
let app = &apps[0];
|
let app = &apps[0];
|
||||||
@@ -182,11 +192,17 @@ 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.class, "source");
|
||||||
assert_eq!(app.status, "pending");
|
assert_eq!(app.status, "pending");
|
||||||
|
|
||||||
// approval creates the member entry with the declared class
|
// approval with --class enrichment creates the member with that class
|
||||||
commands::registry_approve(&dir, "keswick-research.frx.invalid", None).unwrap();
|
commands::registry_approve(
|
||||||
|
&dir,
|
||||||
|
"keswick-research.frx.invalid",
|
||||||
|
None,
|
||||||
|
Some(frxd::config::CLASS_ENRICHMENT),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
let member = signed
|
let member = signed
|
||||||
.doc
|
.doc
|
||||||
@@ -213,7 +229,7 @@ async fn invite_reissues_token_per_node() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
submit_application(&http, &base, "Multi Node").await;
|
submit_application(&http, &base, "Multi Node").await;
|
||||||
let id = "multi-node.frx.invalid";
|
let id = "multi-node.frx.invalid";
|
||||||
commands::registry_approve(&dir, id, None).unwrap();
|
commands::registry_approve(&dir, id, None, None).unwrap();
|
||||||
|
|
||||||
let enroll = |token: String, pubkey: String| {
|
let enroll = |token: String, pubkey: String| {
|
||||||
let http = http.clone();
|
let http = http.clone();
|
||||||
@@ -231,16 +247,22 @@ async fn invite_reissues_token_per_node() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// node 1: the invite from approval
|
// node 1: a single-use handoff invite
|
||||||
|
commands::registry_invite(&dir, id, None).unwrap();
|
||||||
let key1 = Keypair::generate();
|
let key1 = Keypair::generate();
|
||||||
let token1 = invite_token(&dir, id);
|
let token1 = invite_token(&dir, id);
|
||||||
assert!(enroll(token1.clone(), key1.public_hex()).await.is_success());
|
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`
|
// node 2: a fresh token from `registry invite`
|
||||||
commands::registry_invite(&dir, id, None).unwrap();
|
commands::registry_invite(&dir, id, None).unwrap();
|
||||||
let key2 = Keypair::generate();
|
let key2 = Keypair::generate();
|
||||||
let token2 = invite_token(&dir, id);
|
let token2 = invite_token(&dir, id);
|
||||||
assert_ne!(token1, token2);
|
|
||||||
assert!(enroll(token2, key2.public_hex()).await.is_success());
|
assert!(enroll(token2, key2.public_hex()).await.is_success());
|
||||||
|
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
@@ -260,11 +282,11 @@ async fn wizard_enrolls_and_writes_config() {
|
|||||||
let http = reqwest::Client::new();
|
let http = reqwest::Client::new();
|
||||||
submit_application(&http, &base, "Wizard Test").await;
|
submit_application(&http, &base, "Wizard Test").await;
|
||||||
let id = "wizard-test.frx.invalid";
|
let id = "wizard-test.frx.invalid";
|
||||||
commands::registry_approve(&dir, id, None).unwrap();
|
commands::registry_approve(&dir, id, None, None).unwrap();
|
||||||
|
let token = commands::registry_token(&dir, id, None).unwrap();
|
||||||
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
let signed = registry::load_registry(&dir.join("registry.json")).unwrap();
|
||||||
let credentials = format!(
|
let credentials = format!(
|
||||||
"id={id} token={} registry={base}/registry.json ma_key={}",
|
"id={id} token={token} registry={base}/registry.json ma_key={}",
|
||||||
invite_token(&dir, id),
|
|
||||||
signed.doc.ma_key
|
signed.doc.ma_key
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user