Skip to main content

kcode_k1_invites/
lib.rs

1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use hmac::{Hmac, Mac};
3use kcode_k1_invite_projection::{ApplyOutcome, InviteAction, InviteProjection};
4use kcode_k1_peering::K1Peering;
5use kcode_k1_transaction::SubsystemId;
6use kcode_k1_transaction_id::TxId;
7use kcode_k1_txn_ordering::{K1TxnOrdering, Subsystem};
8use sha2::Sha256;
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11use std::str::FromStr;
12use std::sync::{Arc, Condvar, Mutex, MutexGuard};
13use zeroize::Zeroize;
14
15const SUBSYSTEM_NAME: &str = "k1-invites-subsystem";
16const ISSUE_LENGTH: usize = 34;
17const CONSUME_HEADER_LENGTH: usize = 78;
18const REOPEN_REQUIRED: &str = "k1-invites is unavailable; reopen required";
19
20type HmacSha256 = Hmac<Sha256>;
21
22pub struct InviteCode {
23    bytes: [u8; 6],
24}
25
26impl InviteCode {
27    pub fn expose(&self) -> String {
28        URL_SAFE_NO_PAD.encode(self.bytes.as_slice())
29    }
30}
31
32impl FromStr for InviteCode {
33    type Err = String;
34
35    fn from_str(value: &str) -> Result<Self, Self::Err> {
36        let mut code = InviteCode { bytes: [0; 6] };
37        if value.len() != 8 {
38            return Err("invalid invite code".to_owned());
39        }
40        let written = match URL_SAFE_NO_PAD.decode_slice(value, &mut code.bytes) {
41            Ok(written) => written,
42            Err(_) => return Err("invalid invite code".to_owned()),
43        };
44        if written != code.bytes.len() {
45            return Err("invalid invite code".to_owned());
46        }
47        let mut canonical = [0_u8; 8];
48        let encoded = match URL_SAFE_NO_PAD.encode_slice(&code.bytes, &mut canonical) {
49            Ok(encoded) => encoded,
50            Err(_) => {
51                canonical.zeroize();
52                return Err("invalid invite code".to_owned());
53            }
54        };
55        if encoded != canonical.len() || canonical.as_slice() != value.as_bytes() {
56            canonical.zeroize();
57            return Err("invalid invite code".to_owned());
58        }
59        canonical.zeroize();
60        Ok(code)
61    }
62}
63
64impl Drop for InviteCode {
65    fn drop(&mut self) {
66        self.bytes.zeroize();
67    }
68}
69
70pub struct InviteVerifierKey {
71    bytes: [u8; 32],
72}
73
74impl InviteVerifierKey {
75    pub fn from_bytes(bytes: [u8; 32]) -> Self {
76        Self { bytes }
77    }
78}
79
80impl Drop for InviteVerifierKey {
81    fn drop(&mut self) {
82        self.bytes.zeroize();
83    }
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum InviteStatus {
88    Unknown,
89    Unused,
90    Consumed,
91}
92
93#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
94pub struct UserId(TxId);
95
96impl UserId {
97    pub const fn from_tx_id(txid: TxId) -> Self {
98        Self(txid)
99    }
100
101    pub const fn as_tx_id(self) -> TxId {
102        self.0
103    }
104}
105
106#[derive(Clone, Copy, Eq, Hash, PartialEq)]
107pub struct RegistrationKey([u8; 32]);
108
109impl RegistrationKey {
110    pub fn from_bytes(bytes: [u8; 32]) -> Self {
111        Self(bytes)
112    }
113
114    pub fn as_bytes(&self) -> &[u8; 32] {
115        &self.0
116    }
117}
118
119#[derive(Clone, Eq, PartialEq)]
120pub struct Registration {
121    user_id: UserId,
122    registration_key: RegistrationKey,
123    data: Arc<Vec<u8>>,
124}
125
126impl Registration {
127    pub fn user_id(&self) -> UserId {
128        self.user_id
129    }
130
131    pub fn registration_key(&self) -> RegistrationKey {
132        self.registration_key
133    }
134
135    pub fn data(&self) -> &[u8] {
136        self.data.as_slice()
137    }
138}
139
140pub struct K1Invites {
141    subsystem: Arc<InviteSubsystem>,
142    _ordering: Arc<K1TxnOrdering>,
143    peering: Arc<K1Peering>,
144    verifier_key: InviteVerifierKey,
145}
146
147impl K1Invites {
148    pub fn open(
149        root: &Path,
150        ordering: Arc<K1TxnOrdering>,
151        peering: Arc<K1Peering>,
152        verifier_key: InviteVerifierKey,
153    ) -> Result<Self, String> {
154        let projection = InviteProjection::open(root)?;
155        let snapshot = projection.snapshot()?;
156        let checkpoint = snapshot.checkpoint;
157        let mut state = FacadeState::new();
158        state
159            .issues
160            .try_reserve(snapshot.issues.len())
161            .map_err(|_| allocation_error())?;
162        state
163            .issue_commitments
164            .try_reserve(snapshot.issues.len())
165            .map_err(|_| allocation_error())?;
166        state
167            .registrations
168            .try_reserve(snapshot.registrations.len())
169            .map_err(|_| allocation_error())?;
170        state
171            .registration_keys
172            .try_reserve(snapshot.registrations.len())
173            .map_err(|_| allocation_error())?;
174        for issue in snapshot.issues {
175            if state.issues.contains_key(&issue.commitment)
176                || state.issue_commitments.contains_key(&issue.issue_id)
177            {
178                return Err("invite projection snapshot is inconsistent".to_owned());
179            }
180            state.issues.insert(issue.commitment, issue.issue_id);
181            state
182                .issue_commitments
183                .insert(issue.issue_id, issue.commitment);
184        }
185        for accepted in snapshot.registrations {
186            let commitment = accepted.commitment;
187            if state.issue_commitments.get(&accepted.issue_id) != Some(&commitment) {
188                return Err("invite projection snapshot is inconsistent".to_owned());
189            }
190            let registration_key = RegistrationKey::from_bytes(accepted.registration_key);
191            if state.registrations.contains_key(&commitment)
192                || state.registration_keys.contains_key(&registration_key)
193            {
194                return Err("invite projection snapshot is inconsistent".to_owned());
195            }
196            state.registrations.insert(
197                commitment,
198                Registration {
199                    user_id: UserId(accepted.user_id),
200                    registration_key,
201                    data: Arc::new(accepted.data),
202                },
203            );
204            state.registration_keys.insert(registration_key, commitment);
205        }
206        let subsystem = Arc::new(InviteSubsystem {
207            projection: Mutex::new(projection),
208            state: Mutex::new(state),
209        });
210        let callback: Arc<dyn Subsystem> = subsystem.clone();
211        ordering.register_subsystem(subsystem_id()?, checkpoint, callback)?;
212        subsystem.ensure_available()?;
213        Ok(Self {
214            subsystem,
215            _ordering: ordering,
216            peering,
217            verifier_key,
218        })
219    }
220
221    pub fn status(&self, code: &InviteCode) -> Result<InviteStatus, String> {
222        let commitment = invite_commitment(&self.verifier_key, &code.bytes)?;
223        let state = lock_unpoison(&self.subsystem.state);
224        if !state.available {
225            return Err(REOPEN_REQUIRED.to_owned());
226        }
227        Ok(status_from_state(&state, commitment))
228    }
229
230    pub fn create(&self) -> Result<(TxId, InviteCode), String> {
231        enum Resolution {
232            Return(Result<TxId, String>),
233            Retry,
234        }
235
236        loop {
237            self.subsystem.ensure_available()?;
238            let subsystem_id = subsystem_id()?;
239            let mut code = InviteCode { bytes: [0_u8; 6] };
240            getrandom::fill(&mut code.bytes).map_err(|error| error.to_string())?;
241            let commitment = invite_commitment(&self.verifier_key, &code.bytes)?;
242            let reserved = {
243                let mut state = lock_unpoison(&self.subsystem.state);
244                if !state.available {
245                    return Err(REOPEN_REQUIRED.to_owned());
246                }
247                if state.issues.contains_key(&commitment)
248                    || state.pending_issues.contains(&commitment)
249                {
250                    false
251                } else {
252                    state
253                        .pending_issues
254                        .try_reserve(1)
255                        .map_err(|_| allocation_error())?;
256                    state.pending_issues.insert(commitment);
257                    true
258                }
259            };
260            if !reserved {
261                continue;
262            }
263            let payload = issue_payload(commitment);
264            let submission = self.peering.submit_txn(subsystem_id, &payload);
265            let resolution = {
266                let mut state = lock_unpoison(&self.subsystem.state);
267                let pending_was_present = state.pending_issues.remove(&commitment);
268                if !pending_was_present {
269                    state.available = false;
270                    Resolution::Return(Err(REOPEN_REQUIRED.to_owned()))
271                } else if !state.available {
272                    Resolution::Return(Err(REOPEN_REQUIRED.to_owned()))
273                } else {
274                    match submission {
275                        Ok(returned_id) => match state.issues.get(&commitment).copied() {
276                            Some(accepted_id) if accepted_id == returned_id => {
277                                Resolution::Return(Ok(returned_id))
278                            }
279                            Some(_) => Resolution::Retry,
280                            None => Resolution::Return(Err(
281                                "issue transaction was not the winning Issue".to_owned(),
282                            )),
283                        },
284                        Err(error) => match state.issues.get(&commitment).copied() {
285                            Some(accepted_id) => Resolution::Return(Ok(accepted_id)),
286                            None => Resolution::Return(Err(error)),
287                        },
288                    }
289                }
290            };
291            match resolution {
292                Resolution::Return(result) => return result.map(|id| (id, code)),
293                Resolution::Retry => continue,
294            }
295        }
296    }
297
298    pub fn consume_with_data(
299        &self,
300        code: &InviteCode,
301        registration_key: RegistrationKey,
302        data: &[u8],
303    ) -> Result<UserId, String> {
304        self.subsystem.ensure_available()?;
305        let commitment = invite_commitment(&self.verifier_key, &code.bytes)?;
306        let shared_data = Arc::new(fallible_copy(data)?);
307        let candidate_cell = Arc::new(PendingResult::new());
308        let role = {
309            let mut state = lock_unpoison(&self.subsystem.state);
310            if !state.available {
311                return Err(REOPEN_REQUIRED.to_owned());
312            }
313            if let Some(accepted) = state.registrations.get(&commitment) {
314                ConsumeRole::Accepted(accepted.clone())
315            } else if let Some(pending) = state.pending_consumes.get(&commitment) {
316                ConsumeRole::Pending {
317                    registration_key: pending.registration_key,
318                    data: pending.data.clone(),
319                    cell: pending.cell.clone(),
320                }
321            } else {
322                let issue_id = state
323                    .issues
324                    .get(&commitment)
325                    .copied()
326                    .ok_or_else(|| "unknown invite code".to_owned())?;
327                if state.registration_keys.contains_key(&registration_key)
328                    || state
329                        .pending_registration_keys
330                        .contains_key(&registration_key)
331                {
332                    return Err("registration key is already used by another invite".to_owned());
333                }
334                state
335                    .pending_consumes
336                    .try_reserve(1)
337                    .map_err(|_| allocation_error())?;
338                state
339                    .pending_registration_keys
340                    .try_reserve(1)
341                    .map_err(|_| allocation_error())?;
342                state.pending_consumes.insert(
343                    commitment,
344                    PendingConsume {
345                        registration_key,
346                        data: shared_data.clone(),
347                        cell: candidate_cell.clone(),
348                    },
349                );
350                state
351                    .pending_registration_keys
352                    .insert(registration_key, commitment);
353                ConsumeRole::Leader {
354                    issue_id,
355                    data: shared_data.clone(),
356                    cell: candidate_cell.clone(),
357                }
358            }
359        };
360        match role {
361            ConsumeRole::Accepted(accepted) => {
362                if accepted.registration_key == registration_key && accepted.data.as_slice() == data
363                {
364                    Ok(accepted.user_id)
365                } else {
366                    Err("invite was already consumed with different registration data".to_owned())
367                }
368            }
369            ConsumeRole::Pending {
370                registration_key: pending_key,
371                data: pending_data,
372                cell,
373            } => {
374                if pending_key != registration_key || pending_data.as_slice() != data {
375                    return Err("invite has a conflicting consume request in progress".to_owned());
376                }
377                wait_for_pending(&cell)
378            }
379            ConsumeRole::Leader {
380                issue_id,
381                data,
382                cell,
383            } => {
384                let payload = match consume_payload(
385                    issue_id,
386                    commitment,
387                    registration_key,
388                    data.as_slice(),
389                ) {
390                    Ok(payload) => payload,
391                    Err(error) => {
392                        return self.abandon_pending(commitment, registration_key, &cell, error);
393                    }
394                };
395                let subsystem_id = match subsystem_id() {
396                    Ok(subsystem_id) => subsystem_id,
397                    Err(error) => {
398                        return self.abandon_pending(commitment, registration_key, &cell, error);
399                    }
400                };
401                if let Err(error) = self.subsystem.ensure_available() {
402                    return self.abandon_pending(commitment, registration_key, &cell, error);
403                }
404                let submission = self.peering.submit_txn(subsystem_id, &payload);
405                self.complete_pending(
406                    commitment,
407                    registration_key,
408                    data.as_slice(),
409                    &cell,
410                    submission,
411                )
412            }
413        }
414    }
415
416    pub fn registrations(&self) -> Result<Vec<Registration>, String> {
417        self.subsystem.ensure_available()?;
418        let snapshot = {
419            let projection = lock_unpoison(&self.subsystem.projection);
420            projection.snapshot()
421        };
422        let snapshot = match snapshot {
423            Ok(snapshot) => snapshot,
424            Err(error) => {
425                self.subsystem.mark_unavailable();
426                return Err(error);
427            }
428        };
429        self.subsystem.ensure_available()?;
430        let mut registrations = Vec::new();
431        registrations
432            .try_reserve_exact(snapshot.registrations.len())
433            .map_err(|_| allocation_error())?;
434        for accepted in snapshot.registrations {
435            registrations.push(Registration {
436                user_id: UserId(accepted.user_id),
437                registration_key: RegistrationKey::from_bytes(accepted.registration_key),
438                data: Arc::new(accepted.data),
439            });
440        }
441        Ok(registrations)
442    }
443
444    fn abandon_pending(
445        &self,
446        commitment: [u8; 32],
447        registration_key: RegistrationKey,
448        cell: &Arc<PendingResult>,
449        error: String,
450    ) -> Result<UserId, String> {
451        let consistent = self.remove_pending(commitment, registration_key, cell);
452        let result = if consistent {
453            Err(error)
454        } else {
455            Err(REOPEN_REQUIRED.to_owned())
456        };
457        publish_pending(cell, &result);
458        result
459    }
460
461    fn complete_pending(
462        &self,
463        commitment: [u8; 32],
464        registration_key: RegistrationKey,
465        data: &[u8],
466        cell: &Arc<PendingResult>,
467        submission: Result<TxId, String>,
468    ) -> Result<UserId, String> {
469        let (available, accepted, consistent) = {
470            let mut state = lock_unpoison(&self.subsystem.state);
471            let available = state.available;
472            let accepted = state.registrations.get(&commitment).cloned();
473            let consistent = pending_matches(&state, commitment, registration_key, cell);
474            state.pending_consumes.remove(&commitment);
475            if state.pending_registration_keys.get(&registration_key) == Some(&commitment) {
476                state.pending_registration_keys.remove(&registration_key);
477            }
478            if !consistent {
479                state.available = false;
480            }
481            (available, accepted, consistent)
482        };
483        let result = if !available || !consistent {
484            Err(REOPEN_REQUIRED.to_owned())
485        } else {
486            match submission {
487                Err(error) => match accepted {
488                    Some(accepted)
489                        if accepted.registration_key == registration_key
490                            && accepted.data.as_slice() == data =>
491                    {
492                        Ok(accepted.user_id)
493                    }
494                    _ => Err(error),
495                },
496                Ok(returned_id) => {
497                    if let Some(accepted) = accepted {
498                        if accepted.user_id.as_tx_id() == returned_id
499                            && accepted.registration_key == registration_key
500                            && accepted.data.as_slice() == data
501                        {
502                            Ok(accepted.user_id)
503                        } else {
504                            Err("consume transaction was not the accepted registration".to_owned())
505                        }
506                    } else {
507                        Err("consume transaction was a semantic loser".to_owned())
508                    }
509                }
510            }
511        };
512        publish_pending(cell, &result);
513        result
514    }
515
516    fn remove_pending(
517        &self,
518        commitment: [u8; 32],
519        registration_key: RegistrationKey,
520        cell: &Arc<PendingResult>,
521    ) -> bool {
522        let mut state = lock_unpoison(&self.subsystem.state);
523        let consistent = pending_matches(&state, commitment, registration_key, cell);
524        state.pending_consumes.remove(&commitment);
525        if state.pending_registration_keys.get(&registration_key) == Some(&commitment) {
526            state.pending_registration_keys.remove(&registration_key);
527        }
528        if !consistent {
529            state.available = false;
530        }
531        consistent
532    }
533}
534
535struct InviteSubsystem {
536    projection: Mutex<InviteProjection>,
537    state: Mutex<FacadeState>,
538}
539
540impl InviteSubsystem {
541    fn ensure_available(&self) -> Result<(), String> {
542        if lock_unpoison(&self.state).available {
543            Ok(())
544        } else {
545            Err(REOPEN_REQUIRED.to_owned())
546        }
547    }
548
549    fn apply_if_available(&self, action: InviteAction) -> Result<ApplyOutcome, String> {
550        let projection = lock_unpoison(&self.projection);
551        self.ensure_available()?;
552        projection.apply(action)
553    }
554
555    fn mark_unavailable(&self) {
556        lock_unpoison(&self.state).available = false;
557    }
558
559    fn fault(&self, error: String) -> Result<(), String> {
560        self.mark_unavailable();
561        Err(error)
562    }
563
564    fn accept_issue(&self, id: TxId, commitment: [u8; 32]) -> Result<(), String> {
565        let mut state = lock_unpoison(&self.state);
566        if !state.available {
567            return Err(REOPEN_REQUIRED.to_owned());
568        }
569        if state.issues.contains_key(&commitment) || state.issue_commitments.contains_key(&id) {
570            state.available = false;
571            return Err("accepted Issue contradicted invite indexes".to_owned());
572        }
573        if state.issues.try_reserve(1).is_err() || state.issue_commitments.try_reserve(1).is_err() {
574            state.available = false;
575            return Err(allocation_error());
576        }
577        state.issues.insert(commitment, id);
578        state.issue_commitments.insert(id, commitment);
579        Ok(())
580    }
581
582    fn accept_registration(
583        &self,
584        id: TxId,
585        issue_id: TxId,
586        commitment: [u8; 32],
587        registration_key: [u8; 32],
588        data: &[u8],
589    ) -> Result<(), String> {
590        let copied_data = match fallible_copy(data) {
591            Ok(data) => Arc::new(data),
592            Err(error) => return self.fault(error),
593        };
594        let registration_key = RegistrationKey::from_bytes(registration_key);
595        let mut state = lock_unpoison(&self.state);
596        if !state.available {
597            return Err(REOPEN_REQUIRED.to_owned());
598        }
599        if state.issues.get(&commitment) != Some(&issue_id)
600            || state.issue_commitments.get(&issue_id) != Some(&commitment)
601            || state.registrations.contains_key(&commitment)
602            || state.registration_keys.contains_key(&registration_key)
603        {
604            state.available = false;
605            return Err("accepted registration contradicted invite indexes".to_owned());
606        }
607        if state.registrations.try_reserve(1).is_err()
608            || state.registration_keys.try_reserve(1).is_err()
609        {
610            state.available = false;
611            return Err(allocation_error());
612        }
613        state.registrations.insert(
614            commitment,
615            Registration {
616                user_id: UserId(id),
617                registration_key,
618                data: copied_data,
619            },
620        );
621        state.registration_keys.insert(registration_key, commitment);
622        Ok(())
623    }
624}
625
626impl Subsystem for InviteSubsystem {
627    fn submit_txn(&self, id: TxId, payload: &[u8]) -> Result<(), String> {
628        self.ensure_available()?;
629        let parsed = match parse_payload(id, payload) {
630            Ok(parsed) => parsed,
631            Err(error) => return self.fault(error),
632        };
633        let action = match projection_action(parsed) {
634            Ok(action) => action,
635            Err(error) => return self.fault(error),
636        };
637        let outcome = match self.apply_if_available(action) {
638            Ok(outcome) => outcome,
639            Err(error) => return self.fault(error),
640        };
641        match outcome {
642            ApplyOutcome::IssueAccepted(issue) => match parsed {
643                ParsedAction::Issue { id, commitment }
644                    if issue.issue_id == id && issue.commitment == commitment =>
645                {
646                    self.accept_issue(issue.issue_id, issue.commitment)
647                }
648                _ => self.fault("projection Issue outcome contradicted action".to_owned()),
649            },
650            ApplyOutcome::RegistrationAccepted(accepted) => match parsed {
651                ParsedAction::Consume {
652                    id,
653                    issue_id,
654                    commitment,
655                    registration_key,
656                    data,
657                } if accepted.user_id == id
658                    && accepted.issue_id == issue_id
659                    && accepted.commitment == commitment
660                    && accepted.registration_key == registration_key
661                    && accepted.data.as_slice() == data =>
662                {
663                    self.accept_registration(
664                        accepted.user_id,
665                        accepted.issue_id,
666                        accepted.commitment,
667                        accepted.registration_key,
668                        accepted.data.as_slice(),
669                    )
670                }
671                _ => self.fault("projection registration outcome contradicted action".to_owned()),
672            },
673            ApplyOutcome::Noop => Ok(()),
674        }
675    }
676
677    fn reorg(&self) -> Result<(), String> {
678        self.mark_unavailable();
679        let projection = lock_unpoison(&self.projection);
680        projection.discard()
681    }
682}
683
684struct FacadeState {
685    available: bool,
686    issues: HashMap<[u8; 32], TxId>,
687    issue_commitments: HashMap<TxId, [u8; 32]>,
688    registrations: HashMap<[u8; 32], Registration>,
689    registration_keys: HashMap<RegistrationKey, [u8; 32]>,
690    pending_issues: HashSet<[u8; 32]>,
691    pending_consumes: HashMap<[u8; 32], PendingConsume>,
692    pending_registration_keys: HashMap<RegistrationKey, [u8; 32]>,
693}
694
695impl FacadeState {
696    fn new() -> Self {
697        Self {
698            available: true,
699            issues: HashMap::new(),
700            issue_commitments: HashMap::new(),
701            registrations: HashMap::new(),
702            registration_keys: HashMap::new(),
703            pending_issues: HashSet::new(),
704            pending_consumes: HashMap::new(),
705            pending_registration_keys: HashMap::new(),
706        }
707    }
708}
709
710struct PendingConsume {
711    registration_key: RegistrationKey,
712    data: Arc<Vec<u8>>,
713    cell: Arc<PendingResult>,
714}
715
716struct PendingResult {
717    outcome: Mutex<Option<Result<UserId, String>>>,
718    ready: Condvar,
719}
720
721impl PendingResult {
722    fn new() -> Self {
723        Self {
724            outcome: Mutex::new(None),
725            ready: Condvar::new(),
726        }
727    }
728}
729
730enum ConsumeRole {
731    Accepted(Registration),
732    Pending {
733        registration_key: RegistrationKey,
734        data: Arc<Vec<u8>>,
735        cell: Arc<PendingResult>,
736    },
737    Leader {
738        issue_id: TxId,
739        data: Arc<Vec<u8>>,
740        cell: Arc<PendingResult>,
741    },
742}
743
744#[derive(Clone, Copy)]
745enum ParsedAction<'a> {
746    Issue {
747        id: TxId,
748        commitment: [u8; 32],
749    },
750    Consume {
751        id: TxId,
752        issue_id: TxId,
753        commitment: [u8; 32],
754        registration_key: [u8; 32],
755        data: &'a [u8],
756    },
757}
758
759fn parse_payload<'a>(id: TxId, payload: &'a [u8]) -> Result<ParsedAction<'a>, String> {
760    if payload.len() < 2 || payload[0] != 2 {
761        return Err("malformed k1-invites payload".to_owned());
762    }
763    match payload[1] {
764        1 if payload.len() == ISSUE_LENGTH => Ok(ParsedAction::Issue {
765            id,
766            commitment: array_32(&payload[2..34]),
767        }),
768        2 if payload.len() >= CONSUME_HEADER_LENGTH => Ok(ParsedAction::Consume {
769            id,
770            issue_id: tx_id_from_slice(&payload[2..14]),
771            commitment: array_32(&payload[14..46]),
772            registration_key: array_32(&payload[46..78]),
773            data: &payload[78..],
774        }),
775        _ => Err("malformed k1-invites payload".to_owned()),
776    }
777}
778
779fn projection_action(parsed: ParsedAction<'_>) -> Result<InviteAction, String> {
780    match parsed {
781        ParsedAction::Issue { id, commitment } => Ok(InviteAction::Issue { id, commitment }),
782        ParsedAction::Consume {
783            id,
784            issue_id,
785            commitment,
786            registration_key,
787            data,
788        } => Ok(InviteAction::Consume {
789            id,
790            issue_id,
791            commitment,
792            registration_key,
793            data: fallible_copy(data)?,
794        }),
795    }
796}
797
798fn invite_commitment(verifier_key: &InviteVerifierKey, code: &[u8; 6]) -> Result<[u8; 32], String> {
799    let mut mac = HmacSha256::new_from_slice(&verifier_key.bytes)
800        .map_err(|_| "invalid invite verifier key".to_owned())?;
801    mac.update(b"k1-invite-v2");
802    mac.update(code);
803    let output = mac.finalize().into_bytes();
804    let mut commitment = [0_u8; 32];
805    commitment.copy_from_slice(&output);
806    Ok(commitment)
807}
808
809fn status_from_state(state: &FacadeState, commitment: [u8; 32]) -> InviteStatus {
810    if state.registrations.contains_key(&commitment) {
811        InviteStatus::Consumed
812    } else if state.issues.contains_key(&commitment) {
813        InviteStatus::Unused
814    } else {
815        InviteStatus::Unknown
816    }
817}
818
819fn issue_payload(commitment: [u8; 32]) -> [u8; ISSUE_LENGTH] {
820    let mut payload = [0_u8; ISSUE_LENGTH];
821    payload[0] = 2;
822    payload[1] = 1;
823    payload[2..].copy_from_slice(&commitment);
824    payload
825}
826
827fn consume_payload(
828    issue_id: TxId,
829    commitment: [u8; 32],
830    registration_key: RegistrationKey,
831    data: &[u8],
832) -> Result<Vec<u8>, String> {
833    let total = CONSUME_HEADER_LENGTH
834        .checked_add(data.len())
835        .ok_or_else(|| "consume payload length overflow".to_owned())?;
836    let mut payload = Vec::new();
837    payload
838        .try_reserve_exact(total)
839        .map_err(|_| allocation_error())?;
840    payload.extend_from_slice(&[2, 2]);
841    payload.extend_from_slice(issue_id.as_bytes());
842    payload.extend_from_slice(&commitment);
843    payload.extend_from_slice(registration_key.as_bytes());
844    payload.extend_from_slice(data);
845    Ok(payload)
846}
847
848fn subsystem_id() -> Result<SubsystemId, String> {
849    SubsystemId::from_str(SUBSYSTEM_NAME).map_err(|error| error.to_string())
850}
851
852fn tx_id_from_slice(bytes: &[u8]) -> TxId {
853    let mut raw = [0_u8; 12];
854    raw.copy_from_slice(bytes);
855    TxId::from_bytes(raw)
856}
857
858fn array_32(bytes: &[u8]) -> [u8; 32] {
859    let mut array = [0_u8; 32];
860    array.copy_from_slice(bytes);
861    array
862}
863
864fn fallible_copy(bytes: &[u8]) -> Result<Vec<u8>, String> {
865    let mut copied = Vec::new();
866    copied
867        .try_reserve_exact(bytes.len())
868        .map_err(|_| allocation_error())?;
869    copied.extend_from_slice(bytes);
870    Ok(copied)
871}
872
873fn allocation_error() -> String {
874    "memory allocation failed".to_owned()
875}
876
877fn pending_matches(
878    state: &FacadeState,
879    commitment: [u8; 32],
880    registration_key: RegistrationKey,
881    cell: &Arc<PendingResult>,
882) -> bool {
883    state
884        .pending_consumes
885        .get(&commitment)
886        .is_some_and(|pending| {
887            pending.registration_key == registration_key && Arc::ptr_eq(&pending.cell, cell)
888        })
889        && state.pending_registration_keys.get(&registration_key) == Some(&commitment)
890}
891
892fn publish_pending(cell: &PendingResult, result: &Result<UserId, String>) {
893    {
894        let mut outcome = lock_unpoison(&cell.outcome);
895        *outcome = Some(result.clone());
896    }
897    cell.ready.notify_all();
898}
899
900fn wait_for_pending(cell: &PendingResult) -> Result<UserId, String> {
901    let mut outcome = lock_unpoison(&cell.outcome);
902    loop {
903        if let Some(result) = outcome.as_ref() {
904            return result.clone();
905        }
906        outcome = match cell.ready.wait(outcome) {
907            Ok(outcome) => outcome,
908            Err(poisoned) => poisoned.into_inner(),
909        };
910    }
911}
912
913fn lock_unpoison<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
914    match mutex.lock() {
915        Ok(guard) => guard,
916        Err(poisoned) => poisoned.into_inner(),
917    }
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    fn tx_id(byte: u8) -> TxId {
925        TxId::from_bytes([byte; 12])
926    }
927
928    #[test]
929    fn user_id_converts_from_and_to_tx_id() {
930        let txid = tx_id(6);
931        assert_eq!(UserId::from_tx_id(txid).as_tx_id(), txid);
932    }
933
934    #[test]
935    fn invite_status_uses_only_accepted_state() {
936        let commitment = [4; 32];
937        let registration_key = RegistrationKey::from_bytes([5; 32]);
938        let mut state = FacadeState::new();
939
940        assert_eq!(status_from_state(&state, commitment), InviteStatus::Unknown);
941        state.issues.insert(commitment, tx_id(1));
942        assert_eq!(status_from_state(&state, commitment), InviteStatus::Unused);
943
944        state.pending_consumes.insert(
945            commitment,
946            PendingConsume {
947                registration_key,
948                data: Arc::new(b"pending".to_vec()),
949                cell: Arc::new(PendingResult::new()),
950            },
951        );
952        assert_eq!(status_from_state(&state, commitment), InviteStatus::Unused);
953
954        state.registrations.insert(
955            commitment,
956            Registration {
957                user_id: UserId(tx_id(2)),
958                registration_key,
959                data: Arc::new(b"accepted".to_vec()),
960            },
961        );
962        assert_eq!(
963            status_from_state(&state, commitment),
964            InviteStatus::Consumed
965        );
966
967        let copied = InviteStatus::Consumed;
968        assert_eq!(copied, InviteStatus::Consumed);
969        assert_eq!(format!("{copied:?}"), "Consumed");
970    }
971
972    #[test]
973    fn public_status_survives_restart() {
974        use std::sync::atomic::{AtomicU64, Ordering};
975
976        static NEXT_TEMP_ROOT: AtomicU64 = AtomicU64::new(0);
977
978        let unique = NEXT_TEMP_ROOT.fetch_add(1, Ordering::Relaxed);
979        let root = std::env::temp_dir().join(format!(
980            "kcode-k1-invites-status-{}-{unique}",
981            std::process::id()
982        ));
983        let ordering_root = root.join("ordering");
984        let peering_root = root.join("peering");
985        let invites_root = root.join("invites");
986
987        let ordering =
988            Arc::new(K1TxnOrdering::open(&ordering_root).expect("open temporary ordering"));
989        let peering = Arc::new(
990            K1Peering::open(&peering_root, Arc::clone(&ordering)).expect("open temporary peering"),
991        );
992        let invites = K1Invites::open(
993            &invites_root,
994            Arc::clone(&ordering),
995            Arc::clone(&peering),
996            InviteVerifierKey::from_bytes([7; 32]),
997        )
998        .expect("open temporary invites");
999
1000        let unknown = InviteCode::from_str("AAAAAAAA").expect("canonical unknown code");
1001        assert_eq!(
1002            invites.status(&unknown).expect("unknown status"),
1003            InviteStatus::Unknown
1004        );
1005
1006        let (_, code) = invites.create().expect("create invite");
1007        let retained_code = code.expose();
1008        assert_eq!(
1009            invites.status(&code).expect("unused status"),
1010            InviteStatus::Unused
1011        );
1012        invites
1013            .consume_with_data(&code, RegistrationKey::from_bytes([8; 32]), b"account")
1014            .expect("consume invite");
1015        assert_eq!(
1016            invites.status(&code).expect("consumed status"),
1017            InviteStatus::Consumed
1018        );
1019
1020        drop(invites);
1021        drop(peering);
1022        drop(ordering);
1023
1024        let ordering =
1025            Arc::new(K1TxnOrdering::open(&ordering_root).expect("reopen temporary ordering"));
1026        let peering = Arc::new(
1027            K1Peering::open(&peering_root, Arc::clone(&ordering))
1028                .expect("reopen temporary peering"),
1029        );
1030        let invites = K1Invites::open(
1031            &invites_root,
1032            Arc::clone(&ordering),
1033            Arc::clone(&peering),
1034            InviteVerifierKey::from_bytes([7; 32]),
1035        )
1036        .expect("reopen temporary invites");
1037        let code = InviteCode::from_str(&retained_code).expect("parse retained code");
1038        assert_eq!(
1039            invites.status(&code).expect("restarted status"),
1040            InviteStatus::Consumed
1041        );
1042
1043        drop(invites);
1044        drop(peering);
1045        drop(ordering);
1046        std::fs::remove_dir_all(&root).expect("remove temporary root");
1047    }
1048
1049    #[test]
1050    fn invite_code_is_strict_and_round_trips() {
1051        let code = InviteCode {
1052            bytes: [0, 1, 2, 253, 254, 255],
1053        };
1054        let exposed = code.expose();
1055        assert_eq!(exposed.len(), 8);
1056        let parsed = InviteCode::from_str(&exposed).expect("canonical code");
1057        assert_eq!(parsed.expose(), exposed);
1058        for invalid in ["", "AAAAAAA", "AAAAAAAA=", "AAAAAAAAA", "AAAAAA+/", "åååå"] {
1059            assert!(InviteCode::from_str(invalid).is_err());
1060        }
1061    }
1062
1063    #[test]
1064    fn commitment_uses_the_exact_domain_and_code_bytes() {
1065        let key = InviteVerifierKey::from_bytes([7; 32]);
1066        let code = [1, 2, 3, 4, 5, 6];
1067        let actual = invite_commitment(&key, &code).expect("commitment");
1068        let mut mac = HmacSha256::new_from_slice(&[7; 32]).expect("key");
1069        mac.update(b"k1-invite-v2");
1070        mac.update(&code);
1071        let expected = mac.finalize().into_bytes();
1072        assert_eq!(&actual[..], &expected[..]);
1073    }
1074
1075    #[test]
1076    fn version_two_wire_round_trips() {
1077        let callback_id = tx_id(9);
1078        let issue_id = tx_id(4);
1079        let commitment = [3; 32];
1080        let key = RegistrationKey::from_bytes([5; 32]);
1081        let issue = issue_payload(commitment);
1082        match parse_payload(callback_id, &issue).expect("Issue") {
1083            ParsedAction::Issue {
1084                id,
1085                commitment: parsed,
1086            } => {
1087                assert_eq!(id, callback_id);
1088                assert_eq!(parsed, commitment);
1089            }
1090            ParsedAction::Consume { .. } => panic!("wrong action"),
1091        }
1092        let consume =
1093            consume_payload(issue_id, commitment, key, b"opaque\0bytes").expect("Consume payload");
1094        assert_eq!(consume.len(), CONSUME_HEADER_LENGTH + 12);
1095        match parse_payload(callback_id, &consume).expect("Consume") {
1096            ParsedAction::Consume {
1097                id,
1098                issue_id: parsed_issue,
1099                commitment: parsed_commitment,
1100                registration_key,
1101                data,
1102            } => {
1103                assert_eq!(id, callback_id);
1104                assert_eq!(parsed_issue, issue_id);
1105                assert_eq!(parsed_commitment, commitment);
1106                assert_eq!(registration_key, [5; 32]);
1107                assert_eq!(data, b"opaque\0bytes");
1108            }
1109            ParsedAction::Issue { .. } => panic!("wrong action"),
1110        }
1111    }
1112
1113    #[test]
1114    fn malformed_payloads_are_rejected() {
1115        let id = tx_id(1);
1116        let mut extended_issue = vec![0; ISSUE_LENGTH + 1];
1117        extended_issue[0] = 2;
1118        extended_issue[1] = 1;
1119        let mut short_consume = vec![0; CONSUME_HEADER_LENGTH - 1];
1120        short_consume[0] = 2;
1121        short_consume[1] = 2;
1122        let malformed = vec![
1123            Vec::new(),
1124            vec![2],
1125            vec![1, 1],
1126            vec![2, 3],
1127            vec![2, 1],
1128            extended_issue,
1129            short_consume,
1130        ];
1131        for payload in malformed {
1132            assert!(parse_payload(id, &payload).is_err());
1133        }
1134    }
1135
1136    #[test]
1137    fn apply_waiting_behind_reorg_linearization_does_not_reach_projection() {
1138        use std::sync::atomic::{AtomicU64, Ordering};
1139        use std::sync::mpsc;
1140        use std::thread;
1141
1142        static NEXT_TEMP_ROOT: AtomicU64 = AtomicU64::new(0);
1143
1144        let unique = NEXT_TEMP_ROOT.fetch_add(1, Ordering::Relaxed);
1145        let root = std::env::temp_dir().join(format!(
1146            "kcode-k1-invites-reorg-{}-{unique}",
1147            std::process::id()
1148        ));
1149        let projection = InviteProjection::open(&root).expect("open temporary projection");
1150        let subsystem = Arc::new(InviteSubsystem {
1151            projection: Mutex::new(projection),
1152            state: Mutex::new(FacadeState::new()),
1153        });
1154        let projection_guard = lock_unpoison(&subsystem.projection);
1155        let worker_subsystem = Arc::clone(&subsystem);
1156        let (ready_sender, ready_receiver) = mpsc::channel();
1157        let worker = thread::spawn(move || {
1158            ready_sender.send(()).expect("signal helper readiness");
1159            worker_subsystem.apply_if_available(InviteAction::Issue {
1160                id: tx_id(8),
1161                commitment: [9; 32],
1162            })
1163        });
1164
1165        ready_receiver.recv().expect("receive helper readiness");
1166        subsystem.mark_unavailable();
1167        drop(projection_guard);
1168
1169        match worker.join().expect("join helper thread") {
1170            Err(error) => assert_eq!(error, REOPEN_REQUIRED),
1171            Ok(_) => panic!("unavailable helper unexpectedly applied the Issue"),
1172        }
1173        drop(subsystem);
1174
1175        let projection = InviteProjection::open(&root).expect("reopen temporary projection");
1176        let snapshot = projection
1177            .snapshot()
1178            .expect("snapshot temporary projection");
1179        assert!(snapshot.checkpoint.is_none());
1180        assert!(snapshot.issues.is_empty());
1181        assert!(snapshot.registrations.is_empty());
1182        drop(projection);
1183        std::fs::remove_dir_all(&root).expect("remove temporary projection");
1184    }
1185}