Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions hub/docs/PRD_COMMITMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,10 +325,25 @@ K5 adds four things.
- If the join stops in between, the invite is spent on a key that only its holder can finish with. That key's page still shows the button and its retry completes the admission. Any other key gets 410.
- A contact book that can't be written doesn't undo the join: the binding is on the ledger and the log names the missing address.
- **If law queues the join,** `MemberJoinRequested` carries the invite (`invite_party_id`, `invite_digest`), and the operator's admission binds it under the same lock. If the invite stopped being good while the join waited (withdrawn, or its contact erased or bound by someone else), the admission stands and binds nothing.
- **Expiry is checked when the join is made, not at admission.** A join made with a good invite keeps it while it waits. The operator's approval is the gate, and withdrawing the invite is how the operator stops it.
- **One waiting join per invite (Legion, #961).** A forwarded copy can't queue behind the real invitee, because whoever the operator admitted first would take the contact. It gets 409 and writes nothing, and so does an auto-admitting join while that join waits. Denying the wrong request frees the invite.
- **Under the lock, the admission re-reads the request.** It must still be waiting, and its key must not have become a member meanwhile. Otherwise the approval is refused with 409 and writes nothing: a key admitted another way would take the invite by the member path that the join route refuses.
- **A member who opens an invite** their own join didn't carry is refused with 409, and nothing is written. A forwarded invite must not hand a contact's jobs and address to whichever member opened it. The rare genuine case, a contact who is already a member under another key, goes through the operator.
- Opening an invite again from the browser it bound writes nothing.
- **Open:** the admission notice is a listing the operator reads, not a message the carrier sends. Pushing it to the operator needs an operator address, which the hub doesn't hold yet.

**K5b2 as built.**
- **One id per person.** When `ContactBound` folds, every commitment that names the contact is rewritten to name the member: owner and backup, acknowledgements, deliveries, handoffs, the resolution. `Invites::canonical_event` reads every later commitment event the same way, and the hub writes events through it, so the ledger names the member and a replay agrees with the live hub.
- **Nothing the contact already holds stops working.** These all act for the member:
- a K4 link mailed to the contact before the join;
- an operator relaying in the contact's name;
- a carrier that listed a nudge before the join and reports it after.
- **The operator still sees who.** A member who joined from an invite sent no name, so the operator's views show the contact's name from the book. Erasing the contact erases the name (C3).
- **One person is never owner and backup.** A member never binds an invite their own join didn't carry (K5b, Legion #961). The projection doesn't fold a binding that would merge an open job's owner and backup (`bind_conflict`). The hub checks the same thing before it writes one and refuses with 409 (Legion, #962), so a join is never told it bound a contact that the projection then ignores.
- **A joiner can't take a contact's id.** Commitments read a contact and a member by the same string, so the join route refuses, with 409, a member id that is a contact's party id in the book or a bound party id (Legion, #962). The hub mints contact ids, so a real joiner never collides.
- **Open: a binding has no undo.** A wrong binding now moves the contact's jobs, acknowledgements, deliveries and old links, not just an address. An invite bound to a key that law then refuses stays bound to a non-member, and the contact's jobs then name that key. It fails closed: no one else can use the invite, and the key can't act as a member. An operator unbind (or re-mint) is the remedy, built when a chapter needs it.
- **Replay and handoff nudges (supervisor, #958).** Replaying a ledger derives the K4b handoff nudges for every open commitment whose latest entry is a handoff. A hub that first runs K4b over a ledger that already has handoffs nudges those parties once, about a move that may be old. The live hub predates K1, so it has none; a hub upgraded mid-pilot should expect this.

**Questions asked, and answered (Legion, #959, 10-29 20:17Z):**
- (a) **Does an invite admit on its own?** Yes, because an invite is no more trust than the K4 link the contact already holds. It holds only with the five conditions in (1): atomic once, MAC verified, 7-day life, revocation, and the operator told.
- (b) **Are browsers without WebCrypto Ed25519 refused?** Yes, with "ask your organiser" and no JS fallback library: a bundled library would put an extractable key in page memory. The keys that actually get lost are lost to webviews and eviction, covered in (3).
Expand Down
12 changes: 12 additions & 0 deletions hub/hub-daemon/js-tests/client_keystore.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,16 @@ const run = (ctx, code) => vm.runInContext(code, ctx);
// 5. esc escapes quotes, so a topic id cannot break out of a data attribute.
assert.strictEqual(run(g.ctx, 'esc(`a"b\'c<d>`)'), 'a&quot;b&#39;c&lt;d&gt;');
console.log('ok 5 esc covers quotes');

// 6. A corrupt old key is named as such and left in place, every time it is loaded.
for (const bad of ['{not json', JSON.stringify({ lct_id: 'lct-1', pubHex, privJwk: { kty: 'OKP' } })]) {
const c = makeEnv(false);
c.ls.set('hestia_hub_id', bad);
for (let i = 0; i < 2; i++) {
await assert.rejects(run(c.ctx, 'loadID()'), { message: 'LEGACY_UNREADABLE' });
}
assert.strictEqual(c.ls.get('hestia_hub_id'), bad, 'the unreadable key is kept');
assert.strictEqual(await run(c.ctx, 'idbGet()'), undefined, 'and nothing half-moved');
}
console.log('ok 6 a corrupt old key is named and kept');
})().catch(e => { console.error('FAIL', e); process.exit(1); });
18 changes: 15 additions & 3 deletions hub/hub-daemon/src/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7942,6 +7942,12 @@ async fn submit_join(
let mut adhoc = MapResolver::new();
adhoc.insert(applicant_lct.clone());
let _redeemed = verify_envelope(&envelope, &s.nonces, &adhoc, Utc::now())?;
{
let state = { let l = s.ledger.lock().await; s.projected(&l) };
if commitments::names_a_contact(&s, &state, payload.member_lct_id)? {
return Err(ApiError::conflict("that id is already in use here. Start again to make a new key"));
}
}

// 2a. Commitments K5b: an invite is evidence that the operator chose this person, verified by
// its MAC and date before anything is decided. From here to the binding the commitments lock
Expand Down Expand Up @@ -8500,8 +8506,12 @@ async function loadID(){
// One move from the old store, an extractable JWK in localStorage. Same key, same LCT;
// the JWK goes only once the non-extractable copy is stored.
const raw=localStorage.getItem(LEGACY); if(!raw)return null;
const o=JSON.parse(raw);
const priv=await crypto.subtle.importKey('jwk',o.privJwk,{name:'Ed25519'},false,['sign']);
// An old key that can't be read is left where it is: it may be the person's only copy.
let o,priv;
try{
o=JSON.parse(raw);
priv=await crypto.subtle.importKey('jwk',o.privJwk,{name:'Ed25519'},false,['sign']);
}catch(e){ throw new Error('LEGACY_UNREADABLE'); }
const moved={lct_id:o.lct_id,pubHex:o.pubHex,priv};
await idbPut(moved); localStorage.removeItem(LEGACY); persist();
return moved;
Expand Down Expand Up @@ -8632,7 +8642,9 @@ document.addEventListener('click',ev=>{
' · '+(w.law_present?'governed by law':'<span class="warn">no law</span>');
$('#lawlink').href='/v1/hubs/'+HUBID+'/law';
}catch(e){ $('#hubinfo').innerHTML='<span class="err">could not reach hub: '+esc(e.message)+'</span>'; return; }
try{ ID=await loadID(); }catch(e){ $('#idbody').innerHTML='<span class="err">could not open this browser\'s key store: '+esc(e.message)+'</span>'; }
try{ ID=await loadID(); }catch(e){ $('#idbody').innerHTML='<span class="err">'+(e.message==='LEGACY_UNREADABLE'
?'this browser holds an older key that can\'t be read; it has been left as it was. Ask your organiser.'
:'could not open this browser\'s key store: '+esc(e.message))+'</span>'; }
if(ID||!$('#idbody').textContent)renderID();
loadTopics();
})();
Expand Down
36 changes: 28 additions & 8 deletions hub/hub-daemon/src/rest/commitments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ fn check_leads_role(state: &HubState, role: &str) -> Result<Uuid, ApiError> {
/// person's name. Recorded under the operator's own id it would read as a lead's and send the
/// queue to leads (Legion, #947).
fn check_actor(s: &RestState, state: &HubState, c: &Commitment, by: &str, operator_may: bool) -> Result<(), ApiError> {
let by = state.invites.canonical_id(by);
let by = by.as_str();
if by == c.owner.party_id || by == c.backup.party_id {
return Ok(());
}
Expand Down Expand Up @@ -107,11 +109,16 @@ pub(crate) fn leads_of(state: &HubState, c: &Commitment) -> Vec<String> {
}

/// Run the projection checks, then witness, both under [`COMMITMENT_LOCK`] (the caller holds it).
/// A party a contact's id still names is written as the member they became (K5b2), so the ledger
/// says who acted as the projection reads it.
async fn checked_witness(s: &RestState, event: HubEvent) -> Result<u64, ApiError> {
{
let event = {
let ledger = s.ledger.lock().await;
commitments::check(&s.projected(&ledger).commitments, &event).map_err(refused)?;
}
let state = s.projected(&ledger);
let event = state.invites.canonical_event(&event);
commitments::check(&state.commitments, &event).map_err(refused)?;
event
};
witness_event(s, event).await
}

Expand Down Expand Up @@ -358,12 +365,15 @@ async fn witness_resolution(
}

/// A party for the operator's eyes: the contact book's name, or the member's roster name. An
/// erased contact reads as erased; the ledger keeps only its id.
/// erased contact reads as erased; the ledger keeps only its id. A member who joined from an
/// invite sent no name (C3), so they go by their contact's name while the book holds it.
pub(crate) fn party_view(state: &HubState, book: &ContactBook, p: &PartyRef) -> serde_json::Value {
let contact_name = |id: &str| book.contacts.get(id).map(|c| c.name.clone());
let name = match p.kind {
PartyKind::Contact => book.contacts.get(&p.party_id).map(|c| c.name.clone()),
PartyKind::Contact => contact_name(&p.party_id),
PartyKind::Member => Uuid::parse_str(&p.party_id).ok()
.and_then(|u| state.members.get(&u)).and_then(|m| m.name.clone()),
.and_then(|u| state.members.get(&u)).and_then(|m| m.name.clone())
.or_else(|| state.invites.contact_of(&p.party_id).and_then(contact_name)),
};
serde_json::json!({ "party_id": p.party_id, "kind": p.kind,
"name": name, "erased": p.kind == PartyKind::Contact && !book.contacts.contains_key(&p.party_id) })
Expand Down Expand Up @@ -675,6 +685,8 @@ pub(super) async fn admin_nudge_delivered(
async fn record_delivery(s: &RestState, body: DeliveredBody) -> Result<serde_json::Value, ApiError> {
let _serial = COMMITMENT_LOCK.lock().await;
let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) };
// A carrier that listed the nudge before the contact joined reports the contact's id (K5b2).
let body = DeliveredBody { party_id: state.invites.canonical_id(&body.party_id), ..body };
let c = state.commitments.get(&body.commitment_id)
.ok_or_else(|| refused(Refusal::Unknown(body.commitment_id.clone())))?;
// The party must be one this commitment could have nudged. Not "is owed now": the queue may
Expand Down Expand Up @@ -815,13 +827,19 @@ pub(crate) fn mint_link(key: &[u8; 32], commitment_id: &str, party_id: &str, exp
}

/// What a verified link says, and the digest the ledger records in place of the token.
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct LinkClaim {
pub commitment_id: String,
pub party_id: String,
pub digest: String,
}

/// The claim with its party read as who they are now (K5b2): a link mailed to a contact keeps
/// working after the contact joins, as the member they became.
fn claim_now(state: &HubState, claim: &LinkClaim) -> LinkClaim {
LinkClaim { party_id: state.invites.canonical_id(&claim.party_id), ..claim.clone() }
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum LinkError {
/// Not a token this hub made. Indistinguishable on purpose: no oracle for a forger.
Expand Down Expand Up @@ -939,6 +957,7 @@ fn act_label(act: LinkAct, role: LinkRole, backup: &str) -> String {
async fn act_by_link(s: &RestState, claim: &LinkClaim, act: LinkAct) -> Result<serde_json::Value, ApiError> {
let _serial = COMMITMENT_LOCK.lock().await;
let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) };
let claim = &claim_now(&state, claim);
let c = projected_commitment(&state, &claim.commitment_id)?;
let role = role_of(&state, &c, &claim.party_id).ok_or_else(|| ApiError {
status: StatusCode::FORBIDDEN,
Expand Down Expand Up @@ -1049,6 +1068,7 @@ pub(super) async fn link_page(
Err(page) => return page,
};
let state = { let ledger = s.ledger.lock().await; s.projected(&ledger) };
let claim = claim_now(&state, &claim);
let book = match load_book(&s) {
Ok(book) => book,
Err(e) => {
Expand Down Expand Up @@ -2051,6 +2071,6 @@ mod tests {
};
let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{said}");
assert_eq!(said.matches("\nok ").count() + usize::from(said.starts_with("ok ")), 5, "{said}");
assert_eq!(said.matches("\nok ").count() + usize::from(said.starts_with("ok ")), 6, "{said}");
}
}
Loading
Loading