2 Commits
6 changed files with 209 additions and 79 deletions
+2 -2
View File
@@ -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; 12 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; 12 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).
+1 -1
View File
@@ -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:
+53 -30
View File
@@ -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,13 +761,17 @@ 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() {
let valid = registry::validate_token(&server.dir, &request.id, &request.token)
.unwrap_or(false);
if !valid {
return ( return (
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
Json(serde_json::json!({ "error": error.to_string() })), Json(serde_json::json!({ "error": "unknown invite or member token" })),
) )
.into_response(); .into_response();
} }
}
let result = mutate_registry(&server.dir, |doc| { let result = mutate_registry(&server.dir, |doc| {
let Some(member) = doc let Some(member) = doc
.members .members
@@ -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
View File
@@ -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?,
+69
View File
@@ -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)]
+57 -35
View File
@@ -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();
let id = id.to_string();
let token = token.to_string();
async move {
http.post(format!("{base}/v1/enroll"))
.json(&serde_json::json!({ .json(&serde_json::json!({
"id": id, "id": id, "token": token, "pubkey": pubkey,
"token": invite_token(&dir, id),
"pubkey": key.public_hex(),
})) }))
.send() .send()
.await .await
.unwrap(); .unwrap()
assert!(enrolled.status().is_success()); .status()
let signed = registry::load_registry(&dir.join("registry.json")).unwrap(); }
assert!(registry::authorized_keys(&signed, now_ts()).contains_key(&key.public_hex())); };
// 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
); );