diff --git a/.github/workflows/invite-link-fixtures.yml b/.github/workflows/invite-link-fixtures.yml new file mode 100644 index 0000000..2c8e5c6 --- /dev/null +++ b/.github/workflows/invite-link-fixtures.yml @@ -0,0 +1,28 @@ +name: Invite-link fixtures +on: + pull_request: + paths: + - 'app-components/**' + - 'foundation/**' + - 'transports/**' + - 'features/**' + - 'ideas/group-invite-links*' + - 'ideas/group-invite-links/**' + - 'layout.md' + - 'tests/**' + - '.github/workflows/invite-link-fixtures.yml' + push: + branches: [master] +permissions: + contents: read +jobs: + fixtures: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - run: python -m pip install --require-hashes -r tests/ci-requirements.txt + - run: python -c "import cryptography; print(cryptography.__version__)" + - run: python -m unittest discover -s tests -v diff --git a/app-components/README.md b/app-components/README.md index 2e1c3ef..035f950 100644 --- a/app-components/README.md +++ b/app-components/README.md @@ -264,6 +264,11 @@ bytes but is not part of the current profile. Every Marmot leaf uses the adopted [marmot.member.account-identity-proof.v2](./account-identity-proof-v2.md) LeafNode component. +The proposed optional GroupContext component is: + +- [marmot.group.invite-links.v1](./group-invite-links-v1.md) (`0x800e`) - immutable invite generations and policy; + not adopted or required for baseline conformance. + ## Resolved Direction - Marmot component ids stay in the private-use range for the foreseeable future. diff --git a/app-components/group-invite-links-v1.md b/app-components/group-invite-links-v1.md new file mode 100644 index 0000000..4505fd3 --- /dev/null +++ b/app-components/group-invite-links-v1.md @@ -0,0 +1,120 @@ +# marmot.group.invite-links.v1 + +Status: proposed; not adopted. The allocations below are draft allocations for review. + +## Registry and presence + +- Component id: `0x800e`. +- Name: `marmot.group.invite-links.v1`. +- Location: GroupContext `app_data_dictionary` only. +- Optional feature; not required by baseline Marmot. + +Enabling the feature MUST add this component and require its id in `app_components` in the same authorized Commit. +Every resulting member MUST advertise support. Existing groups without it have no enabled invite links. +This v1 feature may be enabled only in ciphersuite `0x0001` groups with an MLS group id of one through 255 bytes; +a resulting state with this component outside those bounds is invalid. +An unsupported required component fails normal capability negotiation; implementations MUST NOT silently omit it. +This component defines no LeafNode, KeyPackage, GroupInfo, AppEphemeral, or SafeAAD data. +Its [request records](../foundation/invite-link-records.md) use the draft-10 Safe Application Interface for +component-scoped signing with the originating leaf key; that does not require SafeAAD negotiation. + +## State bytes + +Use the [Marmot binary profile](../foundation/canonical-encoding.md), including shortest QUIC vector lengths. + +```text +struct { + opaque link_id[32]; + opaque inbox_pubkey[32]; + opaque bearer_hash[32]; + opaque preview_hash[32]; + uint64 expires_at; + uint8 approval_mode; +} InviteLinkV1; + +struct { + InviteLinkV1 links<0..1096>; +} InviteLinksV1; +``` + +Each entry is 137 bytes. There are at most eight entries, sorted by `link_id` bytes with no duplicates. +`link_id` is a fresh independently random identifier for one immutable invitation generation. It is not a group id, +account identity, device identity, or a derivation of any of them. `inbox_pubkey` is a valid x-only secp256k1 public key +for a fresh independently generated inbox key, unique among this component's entries. +The hashes are SHA-256 outputs. `bearer_hash = SHA-256(bearer)` for the invitation's independently random 32-byte +bearer. `preview_hash` hashes the exact plaintext specified in the +[Nostr preview binding](../transports/nostr-invite-links.md#preview-descriptor). +No private key, bearer, preview key, relay hint, or requester identity is stored in this component. + +`expires_at` is zero for no expiry or a Unix time in seconds in `1..9007199254740991`. +`approval_mode` is zero for manual approval or one for automatic admission. Other values are invalid. +A component decoder MUST consume the whole input and reject malformed keys, bad vector lengths, duplicates, +unsorted entries, invalid values, and trailing bytes without repairing them. + +## Updates, authorization, and removal + +AppDataUpdate bytes are exactly a full `InviteLinksV1` replacement, without a generic payload version field. +Standalone proposal and Commit authorization follow +[candidate-state and candidate-parent authorization](README.md#authorization-evaluation): only an active admin may +propose or commit the change. Inline AppDataUpdate is the default. A control message does not mutate this state. +An empty replacement disables all links while retaining feature negotiation. + +For an id present in both parent and resulting state, its complete entry MUST be byte-identical. Changing mode, +preview, bearer, inbox, or expiry creates a fresh id and inbox. This prevents a copied code from silently acquiring +different semantics. A removed id is revoked. The secret-distribution flow MUST NOT reuse a retired id or inbox. + +For a nonterminal resulting group, if the Commit demotes any candidate-parent active-admin account or contains a +resolved Remove of an active-admin leaf (even if another leaf of that account remains), none of the parent's link ids +or inbox keys may remain in the resulting component. An admin who kept an inbox key can still read old ciphertext; +rotation protects requests sent with the new codes. Holders of an old code may still disclose their account and +package to a former admin who retains its inbox key, even if the new group policy rejects the request. +Promoting an admin does not require rotating links. + +If the Commit demotes its own committer's account, the resulting invite-links vector MUST be empty. The departing +committer MUST NOT create replacement generations while stepping down. A remaining admin creates fresh links in a +later authorized Commit and distributes secrets only to the resulting current admins. The demoted device MUST stop +using and delete cached invitation secrets when demotion is selected; deletion cannot erase ciphertext or copies +already retained elsewhere. + +The adopted [member-departure flow](../protocol-core/member-departure.md) is unchanged: an active admin cannot send +SelfRemove. It first completes an admin-policy demotion with at least one other active admin remaining. That +admin-authorized demotion Commit retires the old invitation generations. A subsequent non-admin SelfRemove-only +Commit does not change this component or trigger rotation. The last admin first promotes a successor; enabling +this feature adds no new departure exception or extra Commit. When links are nonempty, the demotion Commit +also carries the invite-links AppDataUpdate that retires them. + +Fresh-id/key generation and never reusing retired values are producer obligations. The current component cannot +prove a complete history of retired ids at a first join or after history expiry; reintroducing an old entry is not a +history-dependent Commit rejection. Honest admins MUST NOT reintroduce it. A client that retains selected-history +evidence of its retirement MUST NOT reopen revoked requests or automatically admit new requests under that id if it +reappears. Treat it as a retired invitation needing a fresh generation, without altering MLS branch selection. +Revocation's future privacy depends on current admins following the fresh-key rule; a malicious current admin can +always disclose new material too. + +These invariants are checked against the complete resulting state, independently of proposal order. +Clock time MUST NOT affect component-update validity or convergence. Expiry is an admission gate specified by the +[feature flow](../features/group-invite-links.md#expiry-revocation-and-withdrawal), not an automatic state mutation. + +Once enabled, the component MUST remain present and required. An AppDataUpdate remove operation is invalid. +Disbanding follows the adopted lifecycle's restricted Commit shape: this component MUST NOT be modified in that +Commit, and retained entries are inert once the group is disbanded. + +## Versioning and migration + +This is the first proposed component version. No earlier invite-code bytes are interoperable with it. +Breaking state, authorization, or update changes require a new component id and document. +Group activation follows normal required-capability updates and publish-before-apply; enabling links does not grant +any membership. Link ids are not assigned a new meaning when a group is recreated. + +## Conformance cases + +Accept an empty vector and one correctly encoded entry. Reject nine entries, duplicate ids, duplicate inboxes, +longer-than-minimal lengths, invalid keys, trailing bytes, and a changed entry under an existing id. +Reject a non-admin update and an admin-removal Commit that retains an old invitation generation. +Reject an active admin's SelfRemove under the adopted sender check. Accept demotion with retirement followed by a +non-admin SelfRemove-only Commit that leaves this component unchanged. A sole admin must promote a successor first. +Reject a self-demotion Commit that adds replacement links, even when +another admin remains. Accept a later fresh-generation Commit from an admin who stays. +Do not reopen revoked requests if an authorized malicious admin reintroduces a known retired generation. +Accept disabling links through an empty replacement. Reject component removal after enablement. +Accept a structurally valid expired entry during replay; reject automatic admission using it under the feature gate. diff --git a/features/README.md b/features/README.md index 44c447d..1497b5f 100644 --- a/features/README.md +++ b/features/README.md @@ -20,6 +20,8 @@ they used to be described in a MIP. The old-to-new MIP map lives in [../mip-cove - [agent-text-streams-quic.md](./agent-text-streams-quic.md) - experimental QUIC-backed live previews for agent text streams, anchored by normal durable final messages. - [push-notifications.md](./push-notifications.md) - optional native push notification flow. +- [group-invite-links.md](./group-invite-links.md) - proposed private invite links, device consent, admin decisions, + expiry and recovery; not adopted. ## Relationship to app components diff --git a/features/group-invite-links.md b/features/group-invite-links.md new file mode 100644 index 0000000..c5c4405 --- /dev/null +++ b/features/group-invite-links.md @@ -0,0 +1,274 @@ +# Private group invite links v1 + +Status: proposed; not adopted. The requirements describe this draft feature, not baseline Marmot conformance. + +## Surfaces and negotiation + +- [Invite-links component](../app-components/group-invite-links-v1.md): policy, immutable generations and updates. +- [Invite records](../foundation/invite-link-records.md): device consent, refresh, withdrawal and private decisions. +- [Nostr invite extension](../transports/nostr-invite-links.md): codes, preview, envelopes, delivery and bounded decoding. +- Adopted [joining](../protocol-core/joining.md), [publish lifecycle](../protocol-core/publish-lifecycle.md), + [convergence](../protocol-core/convergence.md) and [durability](../protocol-core/durability.md). + +This feature is optional and currently Nostr-only. The component's support/required negotiation gates activation and +invitation. There is no unauthenticated ExternalCommit or alternate membership-add authority. +Clients without support MAY continue in groups that have not enabled the feature, but cannot join an enabled group. +The [walkthrough](../ideas/group-invite-links.md) is non-normative context; this draft does not delegate rules to it. + +## Creating and sharing + +An active admin prepares a fresh invitation generation, independent inbox key, bearer and preview key. It encodes +the immutable preview, computes the component commitments, and activates the entry with an authorized policy Commit. +It MUST wait for publish-before-apply and component activation before advertising the invitation as usable. +Descriptor publication and per-admin secret distribution are separate delivery obligations. Neither creates policy. +The private record and descriptor MUST match the active entry before sharing a code. + +The creator encrypts a grant for each active admin, including itself, and carries those copies in normal group traffic. +A promoted admin or another device of a current admin account already in the group receives a fresh grant from a +current admin. If no current +admin can recover the material, issue a new generation; do not derive the inbox from the group or an account. +Each grant includes the complete code's discovery coordinates as well as its secrets. A recipient MUST retain them +and be able to fetch the descriptor and requests independently, without guessing the group's routing relays. +The component is public to group members; only administrators receive its secret material and private requests. + +The share sheet offers the complete code, a QR when the complete payload fits, and optionally a short URL. Opening a preview MUST NOT send a join +request. The preview is marked unconfirmed and the app explains who can see a request. A short URL host can read the +preview and bearer. A direct code or its QR avoids that host. +Previews contain a bounded inline image in v1; implementations MUST NOT fetch URLs embedded in preview text. + +## Consent and request admission + +After explicit Join, the originating device creates revision zero using the exact KeyPackage it offers and its leaf +signing key. It records the preview bytes the user saw. Request consent is distinct from account identity proof. +The device chooses `valid_until` no more than thirty days after its current time. This deadline cannot be extended +by refresh; another deadline requires a new request id and user consent. + +Before retaining an actionable request, an admin MUST validate its version, canonical encoding, bearer commitment, +account-to-leaf proof, device signature, offer evidence, context and prerequisites. The invitation entry must be +present in current selected group state. Admission rejects an expired request deadline or one more than thirty days +in the receiver's future. These clock gates constrain local processing, not Commit validation or branch selection. +The descriptor/code inbox is not authority to erase or decide another request. + +Two devices of the same account use distinct contexts and offered leaves. Deduplication is by complete context and +revision, not by account alone. The same account may have multiple leaves under adopted Marmot identity rules. +The receiver MUST NOT make a second Add for an already realized context, including after removing and later re-adding +that account through an unrelated operation. A new intended join uses fresh consent and a new context. + +## Admin review and realization + +An admin authenticates control records against the enclosing MLS source-epoch sender and active-admin policy. +Before initiating a new action, it also checks selected current state, current admin eligibility and link generation. +A historical grant does not let a former admin act. Commit authorization always uses the candidate parent; a proposed +admin-policy change cannot grant its committer permission retroactively. + +Manual approval requires an explicit admin choice. Automatic processing additionally requires automatic mode, an +unexpired link, an unexpired request, a fully validated current offer and no observed withdrawal, decline, or conflicting +refresh. Approval produces a normal Add Commit. The admin tracks the exact prepared obligation before any publication. +Unknown publication outcome remains unresolved; restart retries the byte-identical obligation as durability specifies. +The inviter does not generate another Add merely because a status or Welcome delivery failed. + +Competing admins do not acquire locks or consume packages at a coordinator. Ordinary MLS validation and Marmot +convergence decide group state. A prepared losing Commit does not authorize a Welcome; the client reconciles whether +its exact offered leaf was added on the selected branch before preparing further work. Automatic retry revalidates +current prerequisites and consent. A decision record, transport acknowledgment or relay timestamp never selects a +branch. Concurrent decline or withdrawal does not remove a member added by the selected branch. + +After successful Add publication and canonical realization, the admin retains the exact Welcome and publishes it +under the adopted transport binding. It sends a status binding that Welcome, Commit and request revision, and an +encrypted invited record to the admin channel. The logical state is Invited, not Joined. A subsequent convergence +invalidation MUST update the request projection; an earlier receipt does not pin a losing branch. + +The requester handles a Welcome through the adopted first-join flow, including tentative processing, inviter +identity, resulting-state admin authorization and the retained-group check. If the consumed package was offered by +a retained request, the receiver MUST check the corresponding invitation entry against the preserved preview +commitment and policy fields during tentative validation, before durable group storage, package rotation or private +initialization-key deletion. No matching entry or a preview mismatch leaves those mutations unapplied and requires +a new explicit user choice, including when accepting the Welcome as an ordinary invitation instead. The requester +MUST NOT silently swap its preview for a newer descriptor. Missing status does not bypass this consent check. +Waiting for that choice MUST NOT extend private-key retention under the adopted +[KeyPackage lifecycle](../foundation/key-packages.md#selection-and-lifecycle). If the required material is +deleted before a choice is made, the Welcome remains unusable; show a recoverable offer problem and use the +refresh or renewed-consent path below rather than retaining keys past their deletion bound. +When multiple retained contexts offered the same package, any preview used for this check MUST belong to a context +with that exact offer and a matching invitation entry; an unrelated request cannot supply consent. + +Association additionally requires the exact offered package, status Welcome hash and status author to match. +Missing status leaves an otherwise validated invitation unassociated, not invalid, and MUST NOT delay a matching +Welcome. Association may complete after the join when the authenticated status arrives. +Only successful Welcome validation and request association produce Joined. Inviter receipts cannot assert that the +remote device joined. The authenticated inviter remains the first-contact trust root; preview matching is not an +independent proof of intended-group authenticity. + +## Processing private control records + +Only delivered app payloads from the selected history may affect the request projection. The receiver MUST verify +that the record's group id and source epoch match the enclosing MLS message, and that its author is an active admin +at that source epoch. Every batch recipient MUST be an active admin in that authenticated source state. The receiver +MUST NOT accept a nested seal detached from that enclosing message as group authority. + +- A grant must match the complete source-state entry and the current active generation. Verify all secret commitments + before using it. A grant for a retired generation provides no current admission authority. +- A forwarded request must validate its original requester signature, revision ancestry and signed package evidence. + The forwarding admin cannot change those bytes or substitute its own account for the requesting account. +- Withdrawal requires the original device-consent signature. A missing revision-zero binding waits for recovery. +- A decline must name a validated request hash under that context. It suppresses automatic processing across revisions; + it does not change group state. Unknown referenced requests wait for their authenticated evidence. +- An invited claim must name a validated request revision. Before projecting realization, match its Commit hash to + retained selected history and confirm that Commit added the exact offered package. Missing Commit evidence remains + a recoverable prerequisite. The claim alone cannot justify a duplicate Add or asserted membership. + +For new decisions or grants, recheck current author eligibility and generation. Replay preserves attributed historical +facts but does not authorize a former admin to initiate new work. A receiver MUST discard malformed or unauthorized +control semantics without rolling back otherwise-valid MLS processing. Unsupported semantics have no request-state +effect. Invalid bytes map to the adopted rejection vocabulary; missing evidence remains recoverable. + +## Refresh and failed delivery + +A stale, expired, unavailable or consumed offered package produces a recoverable offer problem. The requester may +send a chained refresh signed by the original consent key. The new package's account proof and publication are checked +anew. Relays may deliver refreshes out of order; missing ancestry waits for recovery rather than becoming a rejection. +The fresh leaf key may change without changing the originating consent key. If that consent key is lost, create a +new request with explicit consent. Same-account package discovery does not authorize an admin to substitute a device. + +The eight-revision retention limit counts revision zero plus refreshes; revision seven is the last usable revision. +Further refresh requires a new request and withdrawal of the old context if its consent key is available. +An authenticated withdrawal suppresses automatic work across the entire old context, including an out-of-order refresh. +The highest fully validated unambiguous revision governs new preparation. Conflicting refreshes stop new manual +and automatic admission until renewed consent in a fresh context; they do not discard prepared publication facts. + +A refresh received after an Add is realized does not automatically produce another Add. If the exact Welcome cannot +be recovered or is unusable because the original private key was deleted, show a recoverable invitation problem. +The admin reconciles selected membership and the requester explicitly starts a new join attempt before any replacement +membership is authorized. Existing membership removal/rejoin and retained-group safeguards still apply; this feature +does not define repair-by-Welcome. KeyPackage private material is never retained beyond its adopted deletion bound. + +## Expiry, revocation and withdrawal + +Link expiry stops automatic admission and new normal requests; requests already retained remain eligible for an +explicit manual choice until their own deadline. Requests first discovered after link expiry are marked timing +unverified and manual-only. Request timestamps and randomized NIP-59 timestamps do not establish pre-expiry consent. +A local clock check cannot make a Commit with otherwise valid membership authorization invalid on another client. + +Removing an invitation entry revokes all unfulfilled requests for that generation, including previously seen requests. +Security-driven inbox retirement follows the same rule. It never removes a member already added on the selected +branch, destroys another device's saved copy, or prevents an admin from making an ordinary independent invitation. +Demoting an admin or removing an admin leaf requires the component's generation rotation in the same Commit, +including a removed device whose account retains another admin leaf. Each such change retires every shared code and +cancels every still-pending request for those generations, not just requests handled by the departing admin. The +admin UI MUST explain that effect before preparing the policy change. Requesters need new codes and renewed consent; +promotion alone does not retire links. Voluntary departure uses the adopted demotion before SelfRemove: retire links +at demotion, then leave the component unchanged in a non-admin SelfRemove-only Commit. +A self-demotion Commit leaves links empty; a staying admin creates replacements later. The departing device does not +generate replacement secrets. A sole admin promotes a successor before demotion. Promoting an admin grants no access +until a current admin supplies private material. A known retired generation never reopens closed requests merely by reappearing in +component state. + +Withdrawal or decline suppresses automatic processing of that context. A current admin may approve a declined +request only through a new explicit manual decision while its consent and generation remain valid. A withdrawn +context needs fresh requester consent and a new context. To outsiders, a decline is an account-attributed claim, +not proof of global rejection by all admins. An authenticated Add realization always determines membership. +When retirement is selected, current admins SHOULD send an attributed retired status for each retained open request, +using its validated revision and ordinary status delivery. The requester UI MUST distinguish that notice from a +personal decline or membership removal, show who reported it, and offer obtaining a new code. The notice is not +independent proof of current policy; a former inbox holder could forge such an account-attributed claim. No status +can undo a validated Welcome. If no notice arrives, the requester cannot infer revocation from silence and remains +waiting until its deadline. Status failure never delays the policy Commit. +Disbanding follows the adopted terminal lifecycle; all request processing stops without modifying its Commit shape. + +## Retention, capacity and restart + +Reading, forwarding or acknowledging transport receipt MUST NOT delete or retire a request. Local row hiding is not +a shared decision. Retain or reconstruct the exact context, revision chain, preview-at-consent, authenticated offer +evidence, decisions, publication uncertainty and Welcome association needed to reproduce the result after restart. +Retained group history may expire; current admins can forward device-authenticated records to another current admin. +Absence of a relay copy is neither rejection nor proof that processing completed. + +Open records expire at their signed `valid_until`. An expired context cannot become actionable merely by replay or +restart. Retain its terminal outcome and duplicate-suppression tombstone until at least twenty-four hours after that +deadline; thereafter the signed deadline rejects a replay without needing the tombstone. Outstanding uncertain MLS +publication and convergence facts retain their adopted lifetimes even if a request expires. + +A retired generation's contexts and tombstones no longer count against current admission capacity, because its entry +is absent. Its requests remain revoked. Retain facts still needed for rollback, uncertain publication and recovery +under the adopted lifetimes; capacity release is not permission to erase them. If convergence restores a previously +selected live generation, reconstitute its capacity accounting before admitting work. A retirement on an invalidated +branch no longer counts as selected-history retirement evidence; restore the prior generation's eligible requests +after revalidating current prerequisites. Independently authenticated requester withdrawals remain effective. +Do not treat a malicious +reintroduction as new consent. Retained selected-history retirement evidence keeps it inert for automatic processing. +This lets an admin retire a spam-filled generation and issue a fresh one without waiting for old request deadlines. + +The transport's 100-context per-link and 1000-reservation/tombstone per-group ceilings are capacity gates. Before +admitting an open context, reserve one terminal slot: admission MUST keep open reservations plus retained terminal +contexts across active generations at most 1000. Completing a context converts its reservation to a tombstone without +increasing this count. Duplicates and refreshes reuse the existing reservation. Retirements release current-capacity +reservations while preserving required facts; rollback MUST restore them before any new admission, and a recovered +excess pauses new admission until capacity becomes available. Recovery facts MUST NOT be evicted to force the count +under the admission ceiling. A client MUST NOT evict +still-required facts to admit another request. It stops admitting new contexts when the relevant capacity is reached, +keeps current work recoverable, and exposes capacity separately from declined or joined. Retention release does not +erase another device's copy or grant an outsider deletion authority. A sender retries only with bounded backoff; +resubmission after loss preserves the context and deadline. + +## Deployment profile and user warnings + +The default sharing path SHOULD be the complete code or a fitting QR. Short-link hosting is optional. Before +uploading a code to a short-link service, the app MUST explain that this service can read its secrets and obtain +explicit consent for that disclosure. Hostnames, abuse controls and service retention are deployment policy; they +do not change the code, revoke it or provide membership authority. + +Clients using an external account signer MUST explain the purpose and intended recipient of account-seal and +encryption operations. An encryption-capable signer is a trusted plaintext processor, not only a signing oracle: +[NIP-46](https://github.com/nostr-protocol/nips/blob/master/46.md) passes plaintext to `nip44_encrypt` and returns it +from `nip44_decrypt`. For requests that includes bearer and device data; for admin grants it includes invitation +secrets. The app MUST disclose this trust boundary before enabling that path. Signer denial, unavailability or +unsupported encryption leaves the operation pending or canceled locally, never reported as relay delivery, +admission or a decision by another party. Rewrapping after recovery preserves the logical signed record. + +V1 uses the stated local clock gates without an implicit grace period. A client that detects an unreliable clock +MUST pause new request creation and admission, explain the clock problem, and revalidate after correction. Manual +approval cannot extend the signed deadline or bypass expiry checks that apply to the request. Deployment choices +for time synchronization do not make relay timestamps evidence of pre-expiry consent or change MLS validity. + +## Versioning and adoption checks + +The component id, record version and transport kinds jointly identify this draft. Unknown required component versions +fail normal negotiation; unknown records are ignored with an unsupported outcome and no state effect. +No migration from ad hoc invitation URLs or alternate account-signature schemes is defined. + +The fixed v1 choices are Nostr delivery, immutable generations, original-leaf-key refresh consent, inline bounded +JPEG/PNG previews, and revocation canceling unfulfilled requests. Changes require versioned owning-surface updates. +The deployment profile above settles the privacy and failure requirements while leaving hosting and clock sources +implementation-defined. Neither an external signer nor a public +key-package publication makes the request inbox confidential after its private key leaks. + +## Required conformance scenarios + +Implementations MUST cover: duplicate wraps, a refresh before its parent, conflicting refreshes, two devices sharing +one account, another account's withdrawal, former-admin replay, admin removal with stale grants, preview substitution, +link expiry during offline time, request deadline replay, capacity without eviction, capacity relief through generation retirement, admin demotion then non-admin SelfRemove, +sole-admin succession, self-demotion with replacement links rejected, retired-status attribution and loss, +QR capacity without truncation, Commit publication uncertainty, +Add success with failed Welcome/status delivery, losing-branch invitation invalidation, and process interruption at +each adopted publish boundary. Fixtures exercise both correct bytes and negative authorization, not only happy-path UX. +Also cover preview mismatch with missing status before any irreversible join mutation; an offline grant recipient +using only its granted code; refresh ancestry after all referenced publication slots were replaced; the highest +validated revision with reordered delivery; 999 terminal contexts plus concurrent completion; and retirement rollback +restoring eligibility and capacity while preserving an independent withdrawal. + +## Threat model and adoption checks + +| Adversary or failure | Protection | Residual limit | +| --- | --- | --- | +| Relay drops, duplicates or reorders ciphertext | Retained exact requests, signed ancestry and idempotent retry | Availability is best effort; no retained copy means no recovery | +| Link holder submits unwanted requests | Bearer gate, explicit device consent, bounded admission and manual mode | Automatic mode deliberately admits eligible link holders; a leaked link permits spam | +| Account-key attacker substitutes a package | Original leaf-key consent chain binds the exact offer | Account compromise still allows new account-authorized devices and fresh malicious requests | +| Former admin retains private inbox material | Current policy gates action; admin/device removal rotates generations | Old codes can still disclose new requests to old key holders; old records remain readable/disclosable | +| Malicious admin changes a preview | Immutable component commitment and preserved preview-at-consent | An authorized inviter can create a different group with the same commitment; first-contact trust remains | +| Admins race or restart during publication | Adopted candidate-parent authorization, durable exact obligations and convergence | Private decisions are best effort; an unseen withdrawal cannot revoke an already prepared/published Add | +| Untrusted preview contains an image or URL | Bounded inline rendering, plain text and no external fetch | Recipient and hosting network metadata remain observable | +| External signer handles NIP-44 operations | Explicit disclosure, recipient/purpose UI and truthful failure outcomes | The signer can read plaintext, including invitation secrets; it must be trusted | + +Before adopting or deploying this draft, maintainers must reconcile the proposed ids with the complete registry, +coordinate the Nostr kind allocations and verify draft-10 component-scoped signing, and run cross-implementation MLS, +NIP-59 and external-signer tests. The repository's byte fixtures are partial evidence, not a production security audit. diff --git a/foundation/README.md b/foundation/README.md index b44840d..829ea52 100644 --- a/foundation/README.md +++ b/foundation/README.md @@ -22,6 +22,8 @@ Foundation docs SHOULD change slowly. A change here usually means the whole prot deterministic testing. - [errors.md](./errors.md) - shared result and rejection vocabulary. - [registries.md](./registries.md) - Marmot-owned ids and namespaces. +- [invite-link-records.md](./invite-link-records.md) - proposed device-consent and private admin records for invite links; + not a baseline requirement. ## Layering diff --git a/foundation/application-messages.md b/foundation/application-messages.md index 2cd406b..39141a9 100644 --- a/foundation/application-messages.md +++ b/foundation/application-messages.md @@ -96,6 +96,11 @@ Implementations MAY support a bounded subset of NIP-88 and MUST ignore unsupport rejecting otherwise-valid MLS state. Poll results are authenticated, best-effort group coordination state; they are not anonymous and are not suitable for election-grade voting. +The proposed [private group invite links v1](../features/group-invite-links.md) uses kind `461` for encrypted admin +batches under this same unsigned shape and sender binding. Its payload is the [canonical admin batch](invite-link-records.md#admin-app-batches); +the nested envelope bytes are defined in the [optional Nostr extension](../transports/nostr-invite-links.md#admin-delivery-inside-mls). +This is not adopted baseline behavior. + ## Message edits (kind 1009) Kind `1009` is an in-place replacement of a prior chat message's text. The edit references the original event id via a diff --git a/foundation/invite-link-records.md b/foundation/invite-link-records.md new file mode 100644 index 0000000..48f5b58 --- /dev/null +++ b/foundation/invite-link-records.md @@ -0,0 +1,189 @@ +# Invite-link records v1 + +Status: proposed; not adopted. This document owns the feature's canonical records and device-consent signature. +Delivery envelopes are owned by the [Nostr extension](../transports/nostr-invite-links.md), state by the +[invite-links component](../app-components/group-invite-links-v1.md), and processing by the +[feature](../features/group-invite-links.md). + +## Encoding and scope + +Structures below use the [Marmot binary profile](canonical-encoding.md). Variable fields have shortest QUIC lengths. +Decoders MUST consume the entire record, reject unknown discriminants, reject out-of-bound values, and reject +non-canonical bytes rather than normalize them. Fixed identifiers and hashes are bytes, not hex text. +This first version supports the required MLS ciphersuite `0x0001` only. Other suites require a future record version. + +```text +struct { + uint16 version; + opaque inbox_pubkey[32]; + opaque link_id[32]; + opaque request_id[32]; + opaque requester_account[32]; + opaque consent_key[32]; + uint64 valid_until; +} InviteRequestContextV1; + +struct { + InviteRequestContextV1 context; + uint32 revision; + opaque previous_request_hash[32]; + opaque bearer[32]; + opaque key_package_ref[32]; + opaque transport_offer<1..8192>; +} InviteRequestTBSV1; + +struct { + InviteRequestTBSV1 tbs; + opaque consent_signature[64]; +} InviteRequestV1; +``` + +`version` is exactly one. `request_id` is fresh random bytes created for this account-device attempt; retries preserve +it. Revision values are zero through seven; greater values are invalid in v1. The public keys are valid keys for their stated algorithms. `requester_account` is the account in the offered +KeyPackage's BasicCredential; its adopted +[account identity proof v2](../app-components/account-identity-proof-v2.md) authorizes the offered leaf key. +`key_package_ref` is RFC 9420 MakeKeyPackageRef over the inner KeyPackage, not a publication event id or slot id. +`transport_offer` is the canonical Nostr offer defined by the transport owner; it binds the exact publication and +delivery coordinates into the request, without making those coordinates identity or membership authority. +`valid_until` is a nonzero Unix time in seconds no greater than `9007199254740991`, fixed for the entire context. +Admission and tombstone retention use it as defined by the feature; it is separate from link and package expiry. + +The signature uses the pinned [MLS extensions draft-10 SafeSignWithLabel](https://datatracker.ietf.org/doc/html/draft-ietf-mls-extensions-10#section-4.3) +with the Ed25519 private key corresponding to `consent_key`, component id `0x800e`, operation label `request`, and +content equal to the exact encoded `InviteRequestTBSV1`. Verification uses SafeVerifyWithLabel with the same inputs. +The draft's encoded ComponentOperationLabel contains base label `MLS Component`, that uint16 component id and the +operation label. RFC 9420 SignWithLabel then prefixes those encoded label bytes with `MLS 1.0 ` inside SignContent. +Both upstream structures use RFC 9420 `` variable lengths (one, two or four bytes, maximum `2^30-1`), not fixed TLS +lengths. For these bounded inputs their minimal encodings equal the Marmot profile. No independent IANA signature +label is introduced. The signature proves originating-leaf consent independently of the account-to-leaf proof. + +For revision zero, `previous_request_hash` is all zero bytes and `consent_key` equals the offered LeafNode signature +key. Define `request_hash = SHA-256(encoded InviteRequestV1)`. A refresh increments revision by exactly one, names +the immediately preceding `request_hash`, preserves the complete context and bearer, and is signed by the original +`consent_key`. The refreshed package carries its own valid account identity proof. Its leaf key may differ. +All ancestors through revision zero MUST be available and validated before a refresh is eligible. +An account match or a replaceable publication slot match does not prove originating-device continuity. +For new admission preparation, use the highest fully validated revision in the unambiguous chain. An older revision +MUST NOT start a new Add after that refresh is validated. A missing ancestor does not supersede a validated revision; +retain the incomplete refresh as a recoverable prerequisite. Already prepared obligations keep their adopted +publication and reconciliation rules rather than being silently replaced by new bytes. + +Two distinct fully validated requests at the same revision under one context constitute `conflicting_refresh`. +For a refresh, this requires validated ancestry through revision zero. Before that ancestry is recovered, both +records remain incomplete prerequisites rather than superseding or conflicting with the validated chain. +Conflicts MUST NOT be resolved by arrival time, hash ordering or a manual choice of one conflicting branch. New admission preparation +stops for that context until the +requester withdraws it and starts a fresh request id with renewed consent. `conflicting_refresh` is a feature-local +request-state annotation, not a new inbound rejection or MLS convergence disposition: the individual signed records +remain valid. A blocked admission action maps to `authorization_failed` for ambiguous consent under the +[shared vocabulary](errors.md). Exact byte duplicates are idempotent. +Missing ancestors are recoverable missing prerequisites, not rejection. + +## Withdrawal + +```text +struct { + InviteRequestContextV1 context; +} InviteWithdrawalTBSV1; + +struct { + InviteWithdrawalTBSV1 tbs; + opaque consent_signature[64]; +} InviteWithdrawalV1; +``` + +SafeSignWithLabel uses component id `0x800e`, operation label `withdrawal`, and the encoded TBS, with the same +ComponentOperationLabel and SignContent framing as requests. The signer is the original consent key +from a validated revision-zero request. Withdrawal closes that context across all revisions and never removes a +member. It may arrive before the original request; processing waits for the original binding. If the consent key +is lost, the account may start a fresh request but MUST NOT forge continuity or a withdrawal for that device. +An application may retain that signing key while consent remains open, subject to its existing MLS key lifecycle; +it MUST NOT retain a deleted KeyPackage initialization key or relax adopted deletion rules to support refresh. + +## Status + +```text +struct { + InviteRequestContextV1 context; + opaque request_hash[32]; + uint8 outcome; + opaque commit_hash[32]; + opaque welcome_hash[32]; +} InviteStatusV1; +``` + +Outcomes are `observed=0`, `declined=1`, `invited=2`, and `retired=3`. Unknown values are invalid. For observed, +declined or retired, both hashes are zero. For invited they are SHA-256 hashes of the complete serialized +`MLSMessage` Commit and Welcome +respectively; zero hashes are invalid. The status is authenticated by its transport's admin-account seal. +A status carries no independent proof of current group-admin authority to an outsider. The app MUST attribute +observed/declined/retired claims to that account, not present them as group consensus. A retired notice reports that +the invitation generation was withdrawn, not that this person was rejected or removed from membership. +Invited is provisional until a +matching Welcome passes the adopted join flow and its GroupInfo signer account equals the status author. +The client MUST NOT wait for a status to process an otherwise valid ordinary Welcome. + +## Private admin records + +```text +struct { + opaque group_id<1..255>; + uint64 source_epoch; + opaque link_id[32]; + uint8 action; + opaque body<1..24576>; +} InviteAdminRecordV1; +``` + +`group_id` is the MLS group id and stays inside recipient-encrypted records. `source_epoch` identifies the group +state in which the enclosing MLS application message was authored. Actions and exact body encodings are: + +- `grant=0`: encoded `InviteLinkV1`, then inbox private key `[32]`, then `opaque transport_code<1..8192>` containing + the complete code in the transport's canonical binary encoding. It carries the bearer, preview key and request + discovery coordinates; a grant MUST NOT depend on group routing or external lookup to reconstruct them. +- `forward_request=1`: an encoded `InviteRequestV1`, followed by the transport's length-prefixed authenticated + publication evidence for that offer. +- `withdrawal=2`: an encoded `InviteWithdrawalV1`. +- `declined=3`: an encoded `InviteStatusV1` whose outcome is declined. +- `invited=4`: an encoded `InviteStatusV1` whose outcome is invited. + +No trailing bytes are allowed inside a body. A grant's entry id equals the enclosing `link_id`; the private key +derives its inbox public key. The transport code's id and inbox match that entry, and its bearer hashes to the +entry's commitment. A grant alone cannot authenticate the preview: +the descriptor and plaintext commitment are checked separately. For other actions the context's link id and inbox +must match the relevant invitation generation. The transport validates the admin sender binding, and the feature +validates source/current authorization. Forwarded requester records retain their device signatures. +Unknown actions fail closed for this version; they do not create decisions or change membership. + +## Admin app batches + +The proposed kind `461` unsigned Marmot app event has empty tags and padded-base64 content of exactly this batch: + +```text +struct { + opaque recipient_account[32]; + opaque transport_envelope<1..90000>; +} InviteAdminEnvelopeV1; + +struct { + InviteAdminEnvelopeV1 envelopes<1..262144>; +} InviteAdminBatchV1; +``` + +Each batch contains one through sixteen unique recipients sorted by account bytes. Producers MUST split by both +recipient count and total encoded byte length; sixteen full-sized envelopes do not fit one batch. Larger sets or +payloads use multiple batches. The field's opaque envelope bytes and validation are owned by the +[Nostr binding](../transports/nostr-invite-links.md#admin-delivery-inside-mls). The batch adds no membership or +current-admin authority beyond the authenticated source/current-state checks in the feature. + +## Versioning + +Breaking record or consent-signature changes require a new record version and the corresponding transport/app-event +kind. Implementations MUST NOT reinterpret unknown versions as v1. Account identity proofs retain their own adopted +component version and MUST NOT be repurposed as request-consent signatures. + +## Examples and verification + +[Synthetic fixtures](../tests/README.md) provide complete bytes and hashes for request/refresh and withdrawal signing. +The fixture package/publication references are illustrative; they are not signed MLS KeyPackages or relay events. +Implementers still need the feature's lifecycle and authorization conformance scenarios with real MLS and Nostr stacks. diff --git a/foundation/registries.md b/foundation/registries.md index 4ed4c5b..7f1aa6b 100644 --- a/foundation/registries.md +++ b/foundation/registries.md @@ -142,6 +142,26 @@ Kind `1009` is reserved for message edits — an in-place replacement of a prior single `e` tag referencing the edited event id and `content` is the replacement plaintext. Clients render the latest replacement onto the original row's body, never as a separate transcript row. +## Proposed invite-link allocations + +These values belong to the proposed [private group invite links v1](../features/group-invite-links.md), not adopted +baseline Marmot. They claim draft values within this proposal; they are not upstream IANA or Nostr allocations. + +| Namespace | Value | Meaning | Owner | +| --- | --- | --- | --- | +| Component | `0x800e` | `marmot.group.invite-links.v1`, GroupContext | [component](../app-components/group-invite-links-v1.md) | +| Nostr kind | `459` | Request/withdrawal rumor | [Nostr extension](../transports/nostr-invite-links.md) | +| Nostr kind | `460` | Status rumor | [Nostr extension](../transports/nostr-invite-links.md) | +| Nostr kind | `461` | Private admin rumor and unsigned MLS batch event | [Nostr extension](../transports/nostr-invite-links.md) | +| Nostr kind | `30444` | Encrypted preview descriptor | [Nostr extension](../transports/nostr-invite-links.md) | +| Bech32m HRP | `marmot` | Complete invitation code v1 | [Nostr extension](../transports/nostr-invite-links.md) | +| SafeSignWithLabel operation | `request` under `0x800e` | Device request/refresh consent | [records](invite-link-records.md) | +| SafeSignWithLabel operation | `withdrawal` under `0x800e` | Device withdrawal consent | [records](invite-link-records.md) | + +SafeSignWithLabel uses draft-10's ComponentOperationLabel and RFC 9420's `MLS 1.0 ` prefix. The operations above +are component-scoped domain separators, not independent MLS signature-label registrations or exporters. +The code and request context carry format version one; the component id is its state major-version hook. + ## ALPN and protocol identifiers | Identifier | Use | Document | diff --git a/ideas/README.md b/ideas/README.md index f08b33d..08417f9 100644 --- a/ideas/README.md +++ b/ideas/README.md @@ -17,6 +17,8 @@ Implementations MUST NOT treat an idea document as an interop surface. ## Current ideas - [multi-device.md](./multi-device.md) - one account on several devices: linking, invites, device management, removal. +- [group-invite-links.md](./group-invite-links.md) - illustrated walkthrough of the proposed private invite-link spec, + including its privacy trade-offs and remaining deployment questions. ## From idea to spec diff --git a/ideas/group-invite-links.md b/ideas/group-invite-links.md new file mode 100644 index 0000000..832e4b6 --- /dev/null +++ b/ideas/group-invite-links.md @@ -0,0 +1,162 @@ +# Group invite links + +Status: non-normative walkthrough of a proposed specification. Nothing here changes adopted Marmot conformance. +The complete draft lives in [the feature](../features/group-invite-links.md), +[component](../app-components/group-invite-links-v1.md), [records](../foundation/invite-link-records.md), and +[Nostr extension](../transports/nostr-invite-links.md). Those documents own the rules and bytes. + +Alice wants to share Book club without being online when Bob opens it. Bob sees a preview, chooses Join, and waits +for an admin to invite his device through Marmot's normal Welcome flow. Carol can handle the request too. + +## At a glance + +- The complete invitation is a code. A short URL or QR delivers that code to the app. +- Opening the preview sends no request. Bob chooses Join before sharing his account and offered device with admins. +- The bearer authorizes a request. Bob's device signing key proves consent. An authorized MLS Add creates membership. +- Every current admin can handle requests once they have the private material. No coordinator chooses membership. +- Link expiry stops automatic admission. Pending requests have their own deadlines and can still be reviewed manually. +- An inviter receipt means Invited. Bob's validated, associated Welcome means Joined. +- The preview remains unconfirmed until checked against the Welcome. A match does not independently authenticate the + group: Bob still trusts the inviter shown by the app. + +## Scene 1: Alice shares a link + +![Alice chooses approval and expiry, then shares a complete code or fitting QR. A short URL is optional after disclosure consent.](group-invite-links/scene-1-share.svg) + +Alice chooses manual approval or automatic admission, then an expiry. Her app creates independent inbox and preview +keys and a bearer, and commits the preview and policy to group state before sharing. Changing those choices creates +a new invitation generation. Nobody can silently change the semantics of a copied code. + +The share sheet starts with the complete code or a QR if it fits. A short URL is optional, after Alice consents to +disclosing the invitation secrets to that host. Large codes stay available as +text or through a short URL; the app does not truncate them to fit a QR. The proposed code is Bech32m with the +`marmot` prefix; it carries the encrypted preview's location, its decryption key and the bearer. The bounded picture +is inside the encrypted preview, so the code needs no separate image key or image URL. + +`wn.fo/k3m9qx2a` illustrates a deployment's short URL, not a protocol role or prescribed hostname. That host stores the +complete code and can read its secrets. A direct code or QR avoids that host. A direct HTTPS fragment can carry the +code without putting it in the HTTP request, but page scripts can still read it. + +A short-link page can offer installation instructions and a generic unfurl. If the app is installed, the same link +opens the preview. Deleting a short URL only breaks that lookup; revoking the generation in group state retires the +actual invitation. Short-link alphabet, lifetime and abuse controls remain deployment choices. + +## Scene 2: Bob opens it and asks + +![Without the app Bob sees installation help; with it he sees Book club and can choose Join.](group-invite-links/scene-2-open.svg) + +Bob first sees the name, description and picture, marked unconfirmed. Fetching the encrypted descriptor shares network +metadata with relays, but sends no account-bearing join request. The app renders plain text and bounded images without +following URLs in the preview. + +When Bob chooses Join, his device offers one exact published KeyPackage and signs consent with that package's MLS +leaf signing key. Its existing account identity proof binds the leaf to his account. Those are separate proofs: +account authorization alone does not show that this particular device asked to join. + +The request is gift-wrapped to the invitation inbox and retained before sending. It has an immutable device context +and deadline. A retry keeps the same request; a fresh request is a fresh user choice. Two devices of Bob's account can +ask independently, with different contexts and packages. + +A package refresh follows a signed chain from the original consent key, even if the new package has another leaf key. +A request carries its exact signed publication evidence, and the device can resend earlier revisions even after their +publication slots were replaced. The highest fully validated revision governs a new invitation. A missing ancestor +is a recovery problem. Competing refreshes need fresh consent, rather than an admin choosing a branch. Losing the original consent +key requires a new request rather than pretending account equality proves device continuity. + +## Scene 3: Any current admin handles it + +![Alice and Carol see Bob on their Requests screen and can approve or reject.](group-invite-links/scene-3-requests.svg) + +Alice and Carol can each fetch the encrypted request. They validate its bearer, account binding, originating-device +consent, offered package and current policy. A lock-screen alert can say a request is waiting without naming Bob. +A missing package or expired deadline gets a recoverable explanation rather than a false success. + +Manual mode waits for an admin choice. Automatic mode uses the same validation and only acts while the link and +request are valid, with no observed withdrawal, decline or conflicting refresh. A current admin can manually reconsider +a decline; a withdrawal needs new consent. + +Alice and Carol can act at the same time. Their messages do not elect a leader or pick an MLS branch. Marmot's existing +Commit validation, publish-before-apply and convergence determine whether Bob's exact leaf was added. A losing or +uncertain Add is reconciled before preparing another; failed Welcome or status delivery retries the saved bytes. +A decline or withdrawal does not remove someone already added on the selected branch. + +## Scene 4: Bob gets the Welcome + +![Bob sees the inviter and whether the invitation matches his preserved preview.](group-invite-links/scene-4-welcome.svg) + +After the Add succeeds, Alice sends the ordinary Welcome plus an encrypted status that ties this request revision to +that exact Commit and Welcome. It names Alice through her account seal, not merely the shared inbox key. + +Bob processes the Welcome through Marmot's existing tentative join checks. Before storing a group or consuming the +package, his app checks the preview he approved against the invitation entry in the resulting group, even if no +status arrived. A mismatch needs a new choice and leaves his package material intact. Request association also +checks the exact offered package, the status's Welcome hash and the inviter identity. A missing status leaves a +matching invitation unassociated; it does not delay a valid Welcome, and association can complete later. + +The app compares the preview Bob actually approved, not a newly fetched replacement. A mismatch asks for a fresh +choice. Another Welcome does not close this request. The row becomes Joined only after validation and association. + +A malicious inviter can copy the link commitments into another group. The comparison therefore detects substitution +of the preview, not independent authenticity of Book club. The app shows who authored the Welcome, preserving +[Marmot's first-contact trust limit](../protocol-core/joining.md#welcome-bootstrap-trust). + +## Scene 5: Carol catches up privately + +![Alice sends recipient-encrypted admin copies inside ordinary group traffic.](group-invite-links/scene-5-admins.svg) + +Every member receives ordinary group traffic. Alice therefore encrypts private grants, forwarded requests and decisions +again for each current admin, with Nostr gift wraps and signed account seals inside an unsigned Marmot app event. +Ordinary members can see the admin recipient accounts, but cannot read private keys, bearers or requester records. +The enclosing MLS sender and source-epoch policy authenticate the admin action; the nested seal alone is insufficient. + +Carol can recover from retained group traffic or a current admin's fresh copy. A new admin or another device already +in the group gets a fresh grant, including the complete code and request-relay coordinates. Carol can then fetch +requests independently while Alice is offline. If nobody retains the material, the group creates a new generation. The app does not +claim every lost request can be recovered from relays. + +Reading or forwarding a request does not delete it. Decisions remain recoverable through restart, with bounded +retention and capacity. Full capacity pauses new admission rather than evicting records needed to prevent duplicate +Adds. An uncertain publication keeps its ordinary durability obligation even after the request expires. +Each open request reserves capacity for its eventual terminal record, so concurrent completion does not force +eviction. If convergence invalidates a retirement, the restored generation recovers its eligible requests and capacity; +an independent device withdrawal still applies. + +Demoting an admin or removing an admin device retires all active invitation generations in the same policy Commit. +All old codes stop granting admission, and everyone still waiting needs a new code and fresh consent. Promotion alone +does not do this. An admin who steps down leaves links disabled; a staying admin creates new generations later, so +the departing device never generates their secrets. Old ciphertext stays readable +by anyone who retained its key; rotation protects requests sent using the new codes. Someone opening an old code can +still disclose their identity to an old inbox-key holder. Existing members already know the public component +state, including inbox addresses. Private grant bytes stay inside the recipient encryption. + +## Scene 6: Expiry and retirement + +![New visitors see an expired link; earlier requests keep their own deadlines and late requests need manual review.](group-invite-links/scene-6-expiry.svg) + +Link expiry stops new normal requests and automatic admission. A pending request remains manually reviewable until +its own signed deadline. A request first discovered after expiry is timing-unverified: randomized gift-wrap timestamps +cannot prove the device asked before the link expired. + +Revocation or security-driven retirement cancels all unfulfilled requests for that generation. Withdrawal closes one +device context. None of these removes an existing member. An admin can privately report that the invitation was retired; Bob sees +who reported it and can ask for a new code. Silence is still waiting, not evidence of retirement, and a notice does +not undo a Welcome he already validated. Disbanding uses Marmot's adopted terminal lifecycle and stops all processing. + +A delayed Welcome may become unusable after ordinary KeyPackage private-key deletion. Invited never promises Joined, +and the feature does not prolong private initialization-key retention. The admin reconciles membership before a new +explicit join attempt, preserving existing removal/rejoin safeguards. + +## Privacy and deployment + +The inbox is a random invitation address, separate from account identity and group delivery. Relays still see sizes, +timing and recipient addresses. Publishing a package near the request can correlate activity. Joining is not anonymous. +Signed admin seals can be disclosed outside the group. A leaked bearer permits requests; a leaked inbox key exposes +old encrypted requests; leaking both does not authorize an Add or a group-policy change. + +The complete draft settles request bytes, consent/refresh, status correlation, private admin delivery, revocation and +retention. Complete codes and fitting QRs are the preferred sharing path. Uploading a code to a short-link host needs +consent to disclose its secrets. An external account signer that performs encryption can also see requests and grant +secrets; the app explains that trust boundary. Signer failure is a local problem, not a successful delivery or rejection. +Expiry has no hidden grace period, and a detected clock problem pauses new requests and admission until correction. +Hosting, abuse controls and time synchronization remain deployment choices. The fixed limits and proposed allocations +still require adoption and interoperability testing before production use. diff --git a/ideas/group-invite-links/scene-1-share.svg b/ideas/group-invite-links/scene-1-share.svg new file mode 100644 index 0000000..f6596e6 --- /dev/null +++ b/ideas/group-invite-links/scene-1-share.svg @@ -0,0 +1,93 @@ + +Scene 1: Alice shares an invite +Alice enables invite links for Book club and chooses approval. She shares a complete code or a fitting QR. An optional short link is not created until she consents to disclosing the invitation secrets to its host. + + + + + + + + + + + + + + + +Invite links +Book club +When someone asks + +Approve requests + +Add automatically +Link works until + +7 days +You can make another link +later, with its own expiry. + + + +Alice sets the link + + + + + + +Share +Same invitation, three ways + +Optional short link +Not created yet + +Copy complete code + +Show QR code + +Create optional short link +Complete code or fitting QR first. +Short link needs disclosure consent. +Host can read invitation secrets. + + + +Then she shares it + +creates +Share a complete code or fitting QR first. Create a short link only after disclosure consent. + + diff --git a/ideas/group-invite-links/scene-2-open.svg b/ideas/group-invite-links/scene-2-open.svg new file mode 100644 index 0000000..d19519a --- /dev/null +++ b/ideas/group-invite-links/scene-2-open.svg @@ -0,0 +1,93 @@ + +Scene 2: Bob opens the short link +The same wn.fo link shows Bob how to install Marmot when he has no app, and opens the Book club preview when he does. + + + + + + + + + + +wn.fo/k3m9qx2a + + + + + + +wn.fo +Get Marmot +Someone sent you an invite. +Install the app, then open +this link again. + +App Store + +Google Play +After it's installed, open +this same link. + + + +No app installed + + + + + + +Invite + +B +Book club +Group preview +Thursday nights. Bring +whatever you're reading. + +Preview unconfirmed +Checked when you're invited. + +Join +No request until Join. + + + +App installed + + +The code opens a compatible app. Without one, a link page can offer installation instructions. + + diff --git a/ideas/group-invite-links/scene-3-requests.svg b/ideas/group-invite-links/scene-3-requests.svg new file mode 100644 index 0000000..58409db --- /dev/null +++ b/ideas/group-invite-links/scene-3-requests.svg @@ -0,0 +1,99 @@ + +Scene 3: any admin handles Bob's request +Alice and Carol both see Bob waiting on the Book club request list, with Approve and Reject. + + + + + + + + + + + + + + + +Requests +Book club + +B +Bob +via Book club link + +Waiting + +Approve + +Reject +KeyPackage looks fine. + +Automatic links +While the link is open, +eligible invites run +automatically. Late: review. + + + +Alice + + + + + + +Requests +Book club + +B +Bob +via Book club link + +Waiting + +Approve + +Reject +Same request Alice sees. +Whoever is online can +take it. + + + +Carol, also an admin +One request, every active admin +Approving adds Bob through the normal Welcome. Rejecting him does not remove anyone already in the group. + + diff --git a/ideas/group-invite-links/scene-4-welcome.svg b/ideas/group-invite-links/scene-4-welcome.svg new file mode 100644 index 0000000..4ddaf49 --- /dev/null +++ b/ideas/group-invite-links/scene-4-welcome.svg @@ -0,0 +1,91 @@ + +Scene 4: Bob checks the preview against the invitation +Bob checks the approved preview before joining or consuming his offered package. A mismatch preserves that package and asks for a new choice while showing Alice as the inviter. + + + + + + + + + + + + + + + +Invitation +Book club +Thursday nights + +Preview matches +Matches the preview you saw. +Invited by + +A +Alice + +Join + + + +Preview matches + + + + + + +Invitation + +Preview doesn't match +The link showed a different +group than this invitation. +Invited by + +A +Alice + +Join Alice's group + +Not now + + + +Preview differs from what Bob approved +Bob accepts a person, not a picture +Validate the approved preview before storing the group or consuming the offered package. + + diff --git a/ideas/group-invite-links/scene-5-admins.svg b/ideas/group-invite-links/scene-5-admins.svg new file mode 100644 index 0000000..3b17192 --- /dev/null +++ b/ideas/group-invite-links/scene-5-admins.svg @@ -0,0 +1,76 @@ + +Scene 5: admins share the inbox key inside the group +Alice encrypts a copy of the inbox key to herself and to Carol inside a normal group message. Ordinary members see the envelopes and cannot open them. + + + + + + + + + + + + + +Alice +creates the inbox key +for this link + + + +Group message +seen by members, sealed per admin + +Copy for Alice + +Copy for Carol + + + + +Carol +opens her copy +and sees Bob + + + + +Ordinary members +see that two admin envelopes exist. They already know who the admins are. +They cannot read private inbox keys, bearers, or requester details. +Relays see ordinary group traffic. The envelopes are not published on their own. +Grants include the complete code, so Carol can fetch requests while Alice is offline. + + diff --git a/ideas/group-invite-links/scene-6-expiry.svg b/ideas/group-invite-links/scene-6-expiry.svg new file mode 100644 index 0000000..67fe48c --- /dev/null +++ b/ideas/group-invite-links/scene-6-expiry.svg @@ -0,0 +1,85 @@ + +Scene 6: the link expires +A new visitor sees that the link has expired. Alice still has Bob's earlier request, and a request found only after expiry is marked timing unverified. + + + + + + + + + + + + + + + +Link expired +This invite isn't taking +new people. +Ask an admin for a new +link if you still want in. + + + +Someone new, after expiry + + + + + + +Requests +Bob +Asked while the link worked + +Approve + +Sam + +Timing unverified +Found after expiry. + +Approve + +Reject + + + +Alice still has the queue +Link expiry stops new requests; pending ones keep their own deadlines. +Gift-wrap timestamps are fuzzed, so a request discovered late is not treated as proof it arrived in time. + + diff --git a/layout.md b/layout.md index cf5019b..20de2dc 100644 --- a/layout.md +++ b/layout.md @@ -19,6 +19,7 @@ foundation/ README.md identity.md authorization-proofs.md + invite-link-records.md (proposed) account-identity-proof-v1.md key-packages.md canonical-encoding.md @@ -52,14 +53,17 @@ app-components/ group-encrypted-media-v1.md group-encrypted-media-v2.md group-lifecycle-v1.md + group-invite-links-v1.md (proposed, 0x800e) account-identity-proof-v2.md transports/ README.md nostr.md + nostr-invite-links.md (proposed) quic.md features/ README.md content-moderation.md + group-invite-links.md (proposed) encrypted-media.md encrypted-media-v1.md agent-text-streams-quic.md @@ -67,6 +71,7 @@ features/ ideas/ README.md multi-device.md + group-invite-links.md implementation-model.md ``` diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..de8e236 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,54 @@ +# Invite-link v1 fixtures + +These are executable examples for the proposed [feature](../features/group-invite-links.md), not a production implementation. +All seeds and keys in [vectors/invite-links-v1.json](vectors/invite-links-v1.json) are public synthetic test values. +The package references and event ids are illustrative bytes, not real MLS or signed Nostr publications. + +Run with Python 3.12 or later and `cryptography==50.0.0`. +CI installs the versions and release-artifact hashes in `ci-requirements.txt` using `--require-hashes`. + +```sh +python -m unittest discover -s tests -v +``` + +The tests cover: + +- component size, minimal length prefixes, duplicate and unsorted ids/inboxes, malformed keys and invalid policy; +- complete-code Bech32m encoding, uppercase acceptance, checksum/variant/prefix/version rejection and padding; +- relay ordering, duplicates and WSS/count limits on the fixture's ASCII URL subset; +- exact request preimage and hash, signed refresh continuity, protected fields and withdrawal domain separation; +- selection of the highest complete signed revision, reordered ancestors and conflicting consent branches; +- deterministic preview AEAD and hash, rejection of changed key, AAD or ciphertext; +- cross-bindings among code, component, preview and AAD, component signing domains and revision limits; +- withdrawal signatures, context binding, all five admin actions, expected-generation inbox binding and status context/outcome/hash validation; +- forwarded bearer commitments and decline/invited claims matched to a known request hash; +- admin-batch recipient ordering, uniqueness, counts and byte ceilings; +- preview UTF-8, version, policy, image discriminants and type/length consistency; +- canonical padded base64 and request-delivery evidence presence, size and operation constraints; +- worst-case preview, code, forwarded evidence and nested NIP-44 payload sizes; +- abstract invite-component transitions for its own candidate-parent authorization, immutable generations, self-demotion, + admin-leaf removal, successor requirements and the disband exception; +- agreement among proposed registry entries, their owners, indexes and layout. + +The fixture fields ending in `_hex` are exact bytes. `request_sign_content_hex` includes RFC 9420's two vector fields +and the component-scoped operation label inside the RFC label prefix. The context's consent key is Ed25519, derived from `consent_seed_hex`; the inbox and requester account +are x-only secp256k1 points derived from synthetic private scalars one and two. The preview has no image. +The ciphertext omits the nonce, which is in its separate fixture field, and includes the AEAD tag. +The literal signing assertion follows [draft-10 section 4.1](https://www.ietf.org/archive/id/draft-ietf-mls-extensions-10.html#section-4.1) +and [section 4.3](https://www.ietf.org/archive/id/draft-ietf-mls-extensions-10.html#section-4.3): the fixed base label is +`MLS Component`, followed by the component id and operation label, and SafeSignWithLabel passes that encoded label +to RFC 9420 SignWithLabel. This source-checked literal is not an independently produced MLS-stack vector; obtaining +and comparing such a vector remains an adoption requirement. + +The transition helper assumes authenticated candidate-parent identities and resolved proposals; it is a policy model, +not an MLS Commit processor. Admin-policy changes, Remove authorization and last-leaf/admin coupling are assumed +already validated by the adopted core; this model does not test them. The receiver helpers are intentionally partial. They do not implement full relay URL parsing, RFC 8785, NIP-01/NIP-59, +KeyPackage validation, MLS Commit authentication, Welcome processing or MLS convergence. Implementers must run the +[required lifecycle scenarios](../features/group-invite-links.md#required-conformance-scenarios) with those stacks. +Admin examples validate record structure, device signatures and local bindings; their publication and envelope bytes +are placeholders, with no transport authentication. Context checks cover version, deadline and secp256k1 keys; they +do not validate the MLS account proof or the originating consent-key binding. Image examples check field structure, +not image decoding or rendering. The workflow checks fixture assertions only; passing it does not mean this draft +has been adopted. +The request-delivery helper checks its transport wrapper, not feature admission against group state; a real receiver +must perform the feature's bearer, consent, package and current-policy checks before admitting those records. diff --git a/tests/ci-requirements.txt b/tests/ci-requirements.txt new file mode 100644 index 0000000..6d9343d --- /dev/null +++ b/tests/ci-requirements.txt @@ -0,0 +1,152 @@ +# CI dependencies with hashes for all release artifacts from PyPI. +cryptography==50.0.0 \ + --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ + --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ + --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ + --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ + --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ + --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ + --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ + --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ + --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ + --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ + --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ + --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ + --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ + --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ + --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ + --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ + --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ + --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ + --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ + --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ + --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ + --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ + --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ + --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ + --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ + --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ + --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ + --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ + --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ + --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ + --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ + --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ + --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ + --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ + --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ + --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ + --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ + --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ + --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ + --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ + --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ + --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ + --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ + --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ + --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ + --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 diff --git a/tests/test_invite_links_v1.py b/tests/test_invite_links_v1.py new file mode 100644 index 0000000..966ee18 --- /dev/null +++ b/tests/test_invite_links_v1.py @@ -0,0 +1,836 @@ +"""Executable examples for the proposed invite profile, not a production client. + +These fixtures check canonical bytes, consent binding, preview authentication and +bounds, plus an abstract candidate-parent transition model. They deliberately do +not implement MLS, NIP-59 or state convergence. +""" +import hashlib +import base64 +import json +from pathlib import Path +import re +import unittest + +from cryptography.exceptions import InvalidSignature, InvalidTag +from cryptography.hazmat.primitives.asymmetric import ed25519, ec +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 + +ROOT = Path(__file__).resolve().parents[1] +V = json.loads((ROOT / 'tests/vectors/invite-links-v1.json').read_text()) +CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + + +def qlen(n): + for size, bound, flag in [(1, 64, 0), (2, 16384, 0x4000), + (4, 1 << 30, 0x80000000), (8, 1 << 62, 0xc000000000000000)]: + if 0 <= n < bound: + return (n | flag).to_bytes(size, 'big') + raise ValueError('length') + + +def vector(b): + return qlen(len(b)) + b + + +def sign_content(operation, content, component_id=0x800e): + operation_label = vector(b'MLS Component') + component_id.to_bytes(2, 'big') + vector(operation) + return vector(b'MLS 1.0 ' + operation_label) + vector(content) + + +class Reader: + def __init__(self, b): + self.b = b + self.pos = 0 + + def take(self, n): + if n < 0 or self.pos + n > len(self.b): + raise ValueError('truncated') + out = self.b[self.pos:self.pos+n] + self.pos += n + return out + + def num(self, n): + return int.from_bytes(self.take(n), 'big') + + def vec(self, lo, hi): + first = self.take(1)[0] + size = 1 << (first >> 6) + raw = bytes([first]) + self.take(size - 1) + n = int.from_bytes(raw, 'big') & ((1 << (size*8-2))-1) + if not lo <= n <= hi or raw != qlen(n): + raise ValueError('noncanonical or bound') + return self.take(n) + + def end(self): + if self.pos != len(self.b): + raise ValueError('trailing') + + +def secp_key(b): + ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), b'\x02' + b) + + +def relay_list(b, allow_empty=False): + r = Reader(b) + items = [] + while r.pos < len(b): + url = r.vec(1, 512) + # Fixtures use the adopted profile's ASCII WSS subset. + if not re.fullmatch(rb'wss://[a-z0-9.-]+(?:/[a-z0-9/-]*)?', url): + raise ValueError('relay') + items.append(url) + if not (0 if allow_empty else 1) <= len(items) <= 8 or sorted(set(items)) != items: + raise ValueError('relay list') + return items + + +def code(b): + r = Reader(b) + if r.num(2) != 1: + raise ValueError('version') + inbox = r.take(32) + secp_key(inbox) + fields = [inbox] + [r.take(32) for _ in range(3)] + relays = relay_list(r.vec(1, 4096)) + r.end() + return fields, relays + + +def component(b): + outer = Reader(b) + r = Reader(outer.vec(0, 1096)) + outer.end() + rows = [] + while r.pos < len(r.b): + row = r.take(137) + secp_key(row[32:64]) + if int.from_bytes(row[128:136], 'big') > 9007199254740991 or row[136] > 1: + raise ValueError('policy') + rows.append(row) + ids = [x[:32] for x in rows] + inboxes = [x[32:64] for x in rows] + if ids != sorted(set(ids)) or len(inboxes) != len(set(inboxes)): + raise ValueError('duplicate or order') + return rows + + +def request_context(context): + if len(context) != 170: + raise ValueError('context length') + if context[:2] != b'\x00\x01' or not 0 < int.from_bytes(context[-8:], 'big') <= 9007199254740991: + raise ValueError('context') + secp_key(context[2:34]) + secp_key(context[98:130]) + return context + + +def request(b): + r = Reader(b) + context = request_context(r.take(170)) + rev = r.num(4) + if rev > 7: + raise ValueError('revision') + prev, bearer, kp = r.take(32), r.take(32), r.take(32) + offer = Reader(r.vec(1, 8192)) + event_id = offer.take(32) + relay_list(offer.vec(0, 4096), allow_empty=True) + offer.end() + sig = r.take(64) + r.end() + if rev == 0 and prev != bytes(32): + raise ValueError('initial predecessor') + preimage = sign_content(b'request', b[:-64]) + ed25519.Ed25519PublicKey.from_public_bytes(context[130:162]).verify(sig, preimage) + return context, rev, prev, bearer, kp, event_id + + +def refresh(previous, current): + a, b = request(previous), request(current) + if b[0] != a[0] or b[1] != a[1]+1 or b[2] != hashlib.sha256(previous).digest() or b[3] != a[3]: + raise ValueError('continuity') + + +def latest_revision(records): + """Select a signed chain; assumes externally validated package/account evidence.""" + decoded = {record: request(record) for record in records} + if len({fields[0] for fields in decoded.values()}) != 1: + raise ValueError('different contexts') + complete = {record for record, fields in decoded.items() if fields[1] == 0} + for rev in range(1, 8): + for record, fields in decoded.items(): + if fields[1] != rev: + continue + for ancestor in list(complete): + if decoded[ancestor][1] != rev-1: + continue + try: + refresh(ancestor, record) + except ValueError: + continue + complete.add(record) + for rev in range(8): + if sum(decoded[record][1] == rev for record in complete) > 1: + raise ValueError('conflicting refresh') + return max(complete, key=lambda record: decoded[record][1]) if complete else None + + +def polymod(values): + chk = 1 + gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i, g in enumerate(gen): + if top >> i & 1: + chk ^= g + return chk + + +def convert(data, a, b, pad): + acc = bits = 0 + out = [] + for value in data: + if value >> a: + raise ValueError('word') + acc = ((acc << a) | value) & ((1 << (a+b-1))-1) + bits += a + while bits >= b: + bits -= b + out.append((acc >> bits) & ((1 << b)-1)) + if pad and bits: + out.append((acc << (b-bits)) & ((1 << b)-1)) + elif not pad and (bits >= a or ((acc << (b-bits)) & ((1 << b)-1))): + raise ValueError('padding') + return out + + +def bech32m(b, checksum=0x2bc830a3, hrp='marmot'): + data = convert(b, 8, 5, True) + expanded = [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + check = polymod(expanded + data + [0]*6) ^ checksum + return hrp + '1' + ''.join(CHARSET[x] for x in data + [(check >> (5*(5-i))) & 31 for i in range(6)]) + + +def decode_code(s): + if len(s) > 7000 or s != s.lower() and s != s.upper(): + raise ValueError('case or size') + s = s.lower() + hrp, sep, payload = s.rpartition('1') + if hrp != 'marmot' or not sep or len(payload) < 6: + raise ValueError('prefix') + try: + data = [CHARSET.index(x) for x in payload] + except ValueError: + raise ValueError('alphabet') from None + expanded = [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + if polymod(expanded + data) != 0x2bc830a3: + raise ValueError('checksum') + b = bytes(convert(data[:-6], 5, 8, False)) + code(b) + return b + + +def padded_len(n): + if n <= 32: + return 32 + power = 1 << (n-1).bit_length() + chunk = 32 if power <= 256 else power // 8 + return chunk * ((n-1)//chunk + 1) + + +def nip44_length(n): + return 4 * ((1 + 32 + 2 + padded_len(n) + 32 + 2)//3) + + +def parse_status(b): + r = Reader(b) + request_context(r.take(170)) + r.take(32) + outcome = r.num(1) + hashes = [r.take(32), r.take(32)] + r.end() + if outcome not in [0, 1, 2, 3] or any((h == bytes(32)) != (outcome != 2) for h in hashes): + raise ValueError('status') + return outcome + + +def withdrawal(b): + r = Reader(b) + context = request_context(r.take(170)) + signature = r.take(64) + r.end() + ed25519.Ed25519PublicKey.from_public_bytes(context[130:162]).verify( + signature, sign_content(b'withdrawal', context)) + return context + + +def admin_batch(b): + outer = Reader(b) + r = Reader(outer.vec(1, 262144)) + outer.end() + recipients = [] + while r.pos < len(r.b): + recipient = r.take(32) + secp_key(recipient) + r.vec(1, 90000) # Opaque transport bytes; no NIP-59 verification here. + recipients.append(recipient) + if not 1 <= len(recipients) <= 16 or recipients != sorted(set(recipients)): + raise ValueError('recipient count or order') + return recipients + + +def preview(b): + r = Reader(b) + if r.num(2) != 1: + raise ValueError('preview version') + inbox, link_id, bearer_hash = r.take(32), r.take(32), r.take(32) + secp_key(inbox) + expires_at, mode = r.num(8), r.num(1) + if expires_at > 9007199254740991 or mode > 1: + raise ValueError('preview policy') + name, description = r.vec(1, 256), r.vec(0, 4096) + name.decode('utf-8') + description.decode('utf-8') + image_type, image = r.num(1), r.vec(0, 49152) + r.end() + if image_type not in [0, 1, 2] or bool(image) != (image_type != 0): + raise ValueError('image type/length') + # The image field remains opaque here. Rendering needs a real bounded decoder. + return inbox, link_id, bearer_hash, expires_at, mode, name, description, image_type, image + + +def canonical_base64(value, decoded_max): + if len(value) > 4*((decoded_max+2)//3): + raise ValueError('encoded size') + decoded = base64.b64decode(value, validate=True) + if len(decoded) > decoded_max or base64.b64encode(decoded) != value: + raise ValueError('noncanonical base64') + return decoded + + +def request_delivery(b): + r = Reader(b) + operation = r.num(1) + record = r.vec(1, 16384) + publication = r.vec(0, 12288) + r.end() + if operation == 0: + request(record) + if not publication: + raise ValueError('missing package evidence') + elif operation == 1: + withdrawal(record) + if publication: + raise ValueError('withdrawal evidence') + else: + raise ValueError('operation') + # Publication bytes are opaque; their NIP-01 authentication is an integration gate. + return operation, record, publication + + +def transition(parent, result, actor, parent_admins, result_admins, *, + removed_leaf_accounts=(), self_remove_accounts=(), + required=True, supported=True, disband=False): + """Abstract policy model; caller supplies already MLS-authenticated facts. + + None means absent component. Leaves resolve to account identities in the + authenticated candidate parent. Adopted core admin-policy updates, Remove + authorization and last-leaf coupling must already pass; this model checks + invite-component policy only and does not authenticate MLS inputs. + """ + old = component(parent) if parent is not None else [] + new = component(result) if result is not None else [] + if parent is not None and result is None: + raise ValueError('component removal') + if result is not None and (not required or not supported): + raise ValueError('capability') + if disband: + if (parent != result or self_remove_accounts or actor not in parent_admins + or set(result_admins) != {actor}): + raise ValueError('restricted disband shape') + return + if not result_admins: + raise ValueError('last admin needs successor') + demoted = set(parent_admins) - set(result_admins) + removed_admin = set(removed_leaf_accounts) & set(parent_admins) + if self_remove_accounts: + if set(self_remove_accounts) & set(parent_admins): + raise ValueError('admin SelfRemove') + if parent != result or removed_leaf_accounts or demoted: + raise ValueError('SelfRemove-only shape') + if (parent != result or demoted or removed_admin) and actor not in parent_admins: + raise ValueError('parent authorization') + retained = {row[:32]: row for row in old} + if any(row[:32] in retained and row != retained[row[:32]] for row in new): + raise ValueError('immutable generation') + if result is not None and (demoted or removed_admin): + if {row[:32] for row in old} & {row[:32] for row in new}: + raise ValueError('retired id retained') + if {row[32:64] for row in old} & {row[32:64] for row in new}: + raise ValueError('retired inbox retained') + if actor in demoted and new: + raise ValueError('departing committer knows replacement keys') + + +def parse_admin(b, expected_entry=bytes.fromhex(V['entry_hex']), + expected_request_hash=bytes.fromhex(V['request_hash'])): + component(vector(expected_entry)) + r = Reader(b) + r.vec(1, 255) + r.take(8) + link_id = r.take(32) + if link_id != expected_entry[:32]: + raise ValueError('generation binding') + action = r.num(1) + body = Reader(r.vec(1, 24576)) + r.end() + if action == 0: + entry = body.take(137) + component(vector(entry)) + scalar = int.from_bytes(body.take(32), 'big') + pub = ec.derive_private_key(scalar, ec.SECP256K1()).public_key().public_numbers().x.to_bytes(32, 'big') + fields, relays = code(body.vec(1, 8192)) + if (entry != expected_entry or entry[:32] != link_id or entry[32:64] != pub + or fields[:2] != [entry[32:64], link_id] + or hashlib.sha256(fields[2]).digest() != entry[64:96]): + raise ValueError('grant binding') + elif action == 1: + # Locate the signature after the request's last vector; preserve those exact bytes. + start = Reader(body.b) + start.take(270) + start.vec(1, 8192) + start.take(64) + original = body.take(start.pos) + fields = request(original) + ctx = fields[0] + if (ctx[34:66] != link_id or ctx[2:34] != expected_entry[32:64] + or hashlib.sha256(fields[3]).digest() != expected_entry[64:96]): + raise ValueError('forward binding') + json.loads(body.vec(1, 12288)) # Illustrative evidence, not NIP-01 verification. + elif action == 2: + ctx = withdrawal(body.take(234)) + if ctx[34:66] != link_id or ctx[2:34] != expected_entry[32:64]: + raise ValueError('withdrawal binding') + elif action in [3, 4]: + status = body.take(267) + if (parse_status(status) != action - 2 or status[34:66] != link_id + or status[2:34] != expected_entry[32:64] + or status[170:202] != expected_request_hash): + raise ValueError('decision binding') + else: + raise ValueError('unsupported example action') + body.end() + return action + + +class InviteFixtures(unittest.TestCase): + def test_frozen_encodings(self): + self.assertEqual(137, len(bytes.fromhex(V['entry_hex']))) + self.assertEqual([bytes.fromhex(V['entry_hex'])], component(bytes.fromhex(V['component_hex']))) + self.assertEqual([], component(b'\x00')) + b = bytes.fromhex(V['code_hex']) + self.assertEqual(V['code_bech32m'], bech32m(b)) + self.assertEqual(b, decode_code(V['code_bech32m'].upper())) + + def test_fixture_cross_bindings(self): + fields, _ = code(bytes.fromhex(V['code_hex'])) + e = bytes.fromhex(V['entry_hex']) + p = Reader(bytes.fromhex(V['preview_hex'])) + self.assertEqual(1, p.num(2)) + self.assertEqual(e[32:64], p.take(32)) + self.assertEqual(fields[1], p.take(32)) + self.assertEqual(e[64:96], p.take(32)) + self.assertEqual(e[128:136], p.take(8)) + self.assertEqual(e[136], p.num(1)) + self.assertEqual(b'Book club', p.vec(1, 256)) + self.assertEqual(b"Thursday nights. Bring whatever you're reading.", p.vec(0, 4096)) + self.assertEqual(0, p.num(1)) + self.assertEqual(b'', p.vec(0, 49152)) + p.end() + self.assertEqual(fields[0], e[32:64]) + self.assertEqual(fields[1], e[:32]) + self.assertEqual(hashlib.sha256(fields[2]).digest(), e[64:96]) + self.assertEqual(V['preview_hash'], e[96:128].hex()) + self.assertEqual(b'\0\1'+fields[0]+fields[1], bytes.fromhex(V['preview_aad_hex'])) + ctx, _, _, bearer, _, _ = request(bytes.fromhex(V['request_hex'])) + self.assertEqual(fields[:2], [ctx[2:34], ctx[34:66]]) + self.assertEqual(fields[2], bearer) + + def test_noncanonical_component_rejections(self): + e = bytes.fromhex(V['entry_hex']) + for b in [b'\x40\x00', b'\x80\x00\x00\x89'+e, vector(e)+b'\0', vector(e*2), + vector(e*9), vector(e[:-1]+b'\x02'), vector(e[:32]+b'\xff'*32+e[64:])]: + with self.subTest(b=b[:8].hex()), self.assertRaises(ValueError): + component(b) + a = b'\x01'*32 + e[32:] + z = b'\xff'*32 + e[32:] + for b in [vector(z+a), vector(a+z)]: + with self.assertRaises(ValueError): + component(b) + + def test_code_rejections(self): + s = V['code_bech32m'] + b = bytes.fromhex(V['code_hex']) + for bad in ['M'+s[1:], s[:-1]+('q' if s[-1] != 'q' else 'p'), + bech32m(b, checksum=1), bech32m(b, hrp='nostr'), + bech32m(b'\0\x02'+b[2:]), 'marmot1'+'q'*7000, + bech32m(b+b'\0'), bech32m(b[:130]+b'\x40\x00')]: + with self.subTest(prefix=bad[:10]), self.assertRaises(ValueError): + decode_code(bad) + for words in [[1], [0, 1], [0, 0, 0]]: + with self.assertRaises(ValueError): + convert(words, 5, 8, False) + + def test_relay_bounds(self): + for items in [[b'wss://b.example', b'wss://a.example'], [b'wss://a.example']*2, + [b'https://a.example'], [b'wss://'+bytes([97+i])+b'.example' for i in range(9)]]: + with self.assertRaises(ValueError): + relay_list(b''.join(vector(x) for x in items)) + + def test_request_signature_and_refresh(self): + r0 = bytes.fromhex(V['request_hex']) + r1 = bytes.fromhex(V['refresh_hex']) + refresh(r0, r1) + self.assertEqual(V['request_hash'], hashlib.sha256(r0).hexdigest()) + self.assertEqual(V['request_sign_content_hex'], + sign_content(b'request', r0[:-64]).hex()) + for offset in [34, 66, 98, 130, 169, 173, 210, 242, len(r0)-1]: + bad = bytearray(r0) + bad[offset] ^= 1 + with self.subTest(offset=offset), self.assertRaises((ValueError, InvalidSignature)): + request(bytes(bad)) + with self.assertRaises(ValueError): + refresh(r1, r0) + with self.assertRaises(ValueError): + request(r0+b'\0') + + def test_signed_refresh_cannot_change_identity_or_bearer(self): + original = bytes.fromhex(V['request_hex']) + valid = bytes.fromhex(V['refresh_hex']) + signer = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(V['consent_seed_hex'])) + for offset in [66, 169, 180, 210]: + bad = bytearray(valid[:-64]) + bad[offset] ^= 1 + changed = bytes(bad) + signed = changed + signer.sign(sign_content(b'request', changed)) + request(signed) # Cryptographically valid, but wrong continuity. + with self.subTest(offset=offset), self.assertRaises(ValueError): + refresh(original, signed) + + def test_withdrawal_domain_and_context(self): + signed = bytes.fromhex(V['withdrawal_hex']) + ctx, signature = signed[:-64], signed[-64:] + key = ed25519.Ed25519PublicKey.from_public_bytes(ctx[130:162]) + key.verify(signature, sign_content(b'withdrawal', ctx)) + with self.assertRaises(InvalidSignature): + key.verify(signature, sign_content(b'request', ctx)) + with self.assertRaises(InvalidSignature): + key.verify(signature, sign_content(b'withdrawal', ctx[:-1]+b'\0')) + + def test_component_signature_domain_and_revision_limit(self): + r = bytes.fromhex(V['request_hex']) + key = ed25519.Ed25519PublicKey.from_public_bytes(r[130:162]) + with self.assertRaises(InvalidSignature): + key.verify(r[-64:], sign_content(b'request', r[:-64], component_id=0x800d)) + signer = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(V['consent_seed_hex'])) + tbs = r[:170]+(8).to_bytes(4, 'big')+r[174:-64] + signed = tbs+signer.sign(sign_content(b'request', tbs)) + with self.assertRaises(ValueError): + request(signed) + + def test_status_and_admin_records(self): + status = bytes.fromhex(V['status_hex']) + self.assertEqual(2, parse_status(status)) + self.assertEqual(3, parse_status(bytes.fromhex(V['retired_status_hex']))) + for outcome in [0, 1]: + self.assertEqual(outcome, parse_status(status[:202]+bytes([outcome])+bytes(64))) + for bad in [status+b'\0', status[:202]+b'\x04'+status[203:], + status[:203]+bytes(32)+status[235:], status[:202]+b'\0'+status[203:]]: + with self.assertRaises(ValueError): + parse_status(bad) + grant = bytes.fromhex(V['grant_record_hex']) + self.assertEqual(0, parse_admin(grant)) + forwarded = bytes.fromhex(V['forward_record_hex']) + self.assertEqual(1, parse_admin(forwarded)) + for bad in [grant+b'\0', grant[:-1]]: + with self.assertRaises(ValueError): + parse_admin(bad) + + def test_grant_carries_complete_code_and_rejects_substitution(self): + entry = bytes.fromhex(V['entry_hex']) + complete_code = bytes.fromhex(V['code_hex']) + prefix = vector(b'synthetic-group')+(7).to_bytes(8, 'big')+entry[:32]+b'\0' + def grant(code_bytes, scalar=1): + return prefix+vector(entry+scalar.to_bytes(32, 'big')+vector(code_bytes)) + self.assertEqual(0, parse_admin(grant(complete_code))) + self.assertEqual([b'wss://relay.example'], code(complete_code)[1]) + for offset in [34, 66]: + changed = bytearray(complete_code) + changed[offset] ^= 1 + with self.assertRaises(ValueError): + parse_admin(grant(bytes(changed))) + with self.assertRaises(ValueError): + parse_admin(grant(complete_code, scalar=2)) + with self.assertRaises(ValueError): + parse_admin(grant(complete_code[:130]+vector(b''))) + + def test_request_delivery_keeps_ancestor_evidence(self): + evidence = b'{"example":"public synthetic publication placeholder"}' + for name in ['request_hex', 'refresh_hex']: + record = bytes.fromhex(V[name]) + delivered = b'\0'+vector(record)+vector(evidence) + self.assertEqual((0, record, evidence), request_delivery(delivered)) + withdrawn = bytes.fromhex(V['withdrawal_hex']) + valid = b'\1'+vector(withdrawn)+vector(b'') + self.assertEqual((1, withdrawn, b''), request_delivery(valid)) + for invalid in [b'\0'+vector(record)+vector(b''), + b'\0'+vector(record)+vector(bytes(12289)), + b'\1'+vector(withdrawn)+vector(evidence), + b'\2'+vector(withdrawn)+vector(b''), valid+b'\0']: + with self.assertRaises(ValueError): + request_delivery(invalid) + + def test_highest_complete_revision_and_conflicts(self): + r0, r1 = [bytes.fromhex(V[name]) for name in ['request_hex', 'refresh_hex']] + self.assertEqual(r1, latest_revision([r1, r0, r1])) + self.assertEqual(r1, latest_revision([r0, r1])) + self.assertIsNone(latest_revision([r1])) + signer = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(V['consent_seed_hex'])) + # A cryptographically valid second child of revision zero is ambiguous consent. + changed = bytearray(r1[:-64]) + changed[242] ^= 1 + branch = bytes(changed)+signer.sign(sign_content(b'request', bytes(changed))) + with self.assertRaises(ValueError): + latest_revision([r0, r1, branch]) + # A revision two with missing revision one cannot supersede revision zero. + tbs = r1[:170]+(2).to_bytes(4, 'big')+hashlib.sha256(r1).digest()+r1[206:-64] + r2 = tbs+signer.sign(sign_content(b'request', tbs)) + self.assertEqual(r0, latest_revision([r2, r0])) + self.assertEqual(r2, latest_revision([r2, r0, r1])) + changed = bytearray(tbs) + changed[242] ^= 1 + r2_branch = bytes(changed)+signer.sign(sign_content(b'request', bytes(changed))) + self.assertEqual(r0, latest_revision([r2_branch, r2, r0])) + with self.assertRaises(ValueError): + latest_revision([r2_branch, r2, r1, r0]) + + def test_status_context_rejections(self): + valid = bytes.fromhex(V['status_hex']) + for offset, replacement in [(0, b'\x00\x02'), (2, b'\xff'*32), + (98, b'\xff'*32), (162, bytes(8)), + (162, (9007199254740992).to_bytes(8, 'big'))]: + invalid = valid[:offset] + replacement + valid[offset+len(replacement):] + with self.subTest(offset=offset), self.assertRaises(ValueError): + parse_status(invalid) + + def test_withdrawal_decoder_and_original_binding(self): + signed = bytes.fromhex(V['withdrawal_hex']) + self.assertEqual(request(bytes.fromhex(V['request_hex']))[0], withdrawal(signed)) + for invalid in [signed[:-1], signed+b'\0', signed[:66]+b'\xff'+signed[67:]]: + with self.assertRaises((ValueError, InvalidSignature)): + withdrawal(invalid) + # Even a correctly signed withdrawal for another context must not close this one. + signer = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(V['consent_seed_hex'])) + different = signed[:66]+b'\xff'+signed[67:170] + other = different + signer.sign(sign_content(b'withdrawal', different)) + self.assertNotEqual(withdrawal(signed), withdrawal(other)) + + def test_all_admin_actions_and_body_bindings(self): + ctx = bytes.fromhex(V['request_hex'])[:170] + invited = bytes.fromhex(V['status_hex']) + declined = invited[:202]+b'\x01'+bytes(64) + prefix = vector(b'synthetic-group')+(7).to_bytes(8, 'big')+ctx[34:66] + examples = {2: bytes.fromhex(V['withdrawal_hex']), 3: declined, 4: invited} + for action, body in examples.items(): + valid = prefix+bytes([action])+vector(body) + with self.subTest(action=action): + self.assertEqual(action, parse_admin(valid)) + for invalid in [valid+b'\0', prefix+bytes([action])+vector(body+b'\0'), + prefix[:-32]+b'\xff'*32+bytes([action])+vector(body)]: + with self.assertRaises(ValueError): + parse_admin(invalid) + if action in [3, 4]: + with self.assertRaises(ValueError): + parse_admin(valid, expected_request_hash=bytes(32)) + altered = body[:170]+bytes(32)+body[202:] + with self.assertRaises(ValueError): + parse_admin(prefix+bytes([action])+vector(altered)) + for action, body in [(3, invited), (4, declined), (5, invited)]: + with self.assertRaises(ValueError): + parse_admin(prefix+bytes([action])+vector(body)) + # A same-link-id entry with a different inbox cannot authorize these contexts. + entry = bytes.fromhex(V['entry_hex']) + other_inbox = ec.derive_private_key(3, ec.SECP256K1()).public_key().public_numbers().x.to_bytes(32, 'big') + other_entry = entry[:32]+other_inbox+entry[64:] + for record in [bytes.fromhex(V['forward_record_hex'])]+[ + prefix+bytes([action])+vector(body) for action, body in examples.items()]: + with self.assertRaises(ValueError): + parse_admin(record, other_entry) + # A valid device signature does not make a different bearer valid for this generation. + signer = ed25519.Ed25519PrivateKey.from_private_bytes(bytes.fromhex(V['consent_seed_hex'])) + tbs = bytearray(bytes.fromhex(V['request_hex'])[:-64]) + tbs[206] ^= 1 + wrong_bearer = bytes(tbs)+signer.sign(sign_content(b'request', bytes(tbs))) + request(wrong_bearer) # Confirm this is an authenticated, structurally valid negative case. + with self.assertRaises(ValueError): + parse_admin(prefix+b'\x01'+vector(wrong_bearer+vector(b'{}'))) + + def test_admin_batch_count_order_and_size(self): + recipients = sorted(ec.derive_private_key(i, ec.SECP256K1()).public_key().public_numbers().x.to_bytes(32, 'big') + for i in range(1, 18)) + # Placeholder envelope bytes exercise only the canonical outer batch structure. + envelope = b'{}' + rows = [key+vector(envelope) for key in recipients] + self.assertEqual(recipients[:16], admin_batch(vector(b''.join(rows[:16])))) + # Kind 461 has different schemas selected by its authenticated container. + # These examples must not be accepted by the opposite structural decoder. + with self.assertRaises(ValueError): + admin_batch(bytes.fromhex(V['grant_record_hex'])) + with self.assertRaises(ValueError): + parse_admin(vector(rows[0])) + for invalid in [vector(b''), vector(b''.join(rows)), vector(rows[0]*2), + vector(rows[1]+rows[0]), vector(rows[0])+b'\0', + vector(recipients[0]+vector(b'')), + vector(recipients[0]+vector(bytes(90001))), + vector(b''.join(k+vector(bytes(90000)) for k in recipients[:3])), + b'\x40\x23'+rows[0]]: + with self.assertRaises(ValueError): + admin_batch(invalid) + + def test_preview_structure_and_image_discriminants(self): + valid = bytes.fromhex(V['preview_hex']) + self.assertEqual(0, preview(valid)[7]) + for image_type in [1, 2]: + # Nonempty bytes check the field contract, not actual image decoding. + self.assertEqual(image_type, preview(valid[:-2]+bytes([image_type])+vector(b'image'))[7]) + invalids = [valid+b'\0', b'\0\2'+valid[2:], valid[:-2]+b'\3\0', + valid[:-2]+b'\1\0', valid[:-2]+b'\0'+vector(b'image'), + valid[:-2]+b'\2'+vector(bytes(49153)), + valid[:107]+vector(b'\xff')+vector(b'')+b'\0\0'] + for invalid in invalids: + with self.assertRaises(ValueError): + preview(invalid) + + def test_canonical_base64_and_predecode_bound(self): + self.assertEqual(b'\x00', canonical_base64(b'AA==', 1)) + for invalid in [b'AB==', b'AA', b'AA===', b'AA==\n', b'AA-_', b'AAA=']: + with self.assertRaises(ValueError): + canonical_base64(invalid, 1) + + def test_preview_crypto_binding(self): + key = bytes.fromhex(V['preview_key_hex']) + aad = bytes.fromhex(V['preview_aad_hex']) + nonce = bytes.fromhex(V['preview_nonce_hex']) + plain = bytes.fromhex(V['preview_hex']) + cipher = bytes.fromhex(V['preview_ciphertext_hex']) + self.assertEqual(cipher, ChaCha20Poly1305(key).encrypt(nonce, plain, aad)) + self.assertEqual(V['preview_hash'], hashlib.sha256(plain).hexdigest()) + for k, a, c in [(bytes(32), aad, cipher), (key, aad[:-1]+b'\xff', cipher), + (key, aad, cipher[:-1]+bytes([cipher[-1]^1]))]: + with self.assertRaises(InvalidTag): + ChaCha20Poly1305(k).decrypt(nonce, c, a) + + def test_nip44_nested_size_budget(self): + # Bound complete JSON objects conservatively (event metadata < 1024 bytes). + admin_record_max = 2+255+8+32+1+4+24576 + rumor_max = 1024 + 4*((admin_record_max+2)//3) + seal_max = 1024 + nip44_length(rumor_max) + wrap_max = 1024 + nip44_length(seal_max) + self.assertLessEqual(rumor_max, 65535) + self.assertLessEqual(seal_max, 65535) + self.assertLessEqual(wrap_max, 90000) + max_request = 170+4+96+2+8192+64 + self.assertLessEqual(max_request+2+12288, 24576) + request_delivery_max = 1+4+16384+2+12288 + request_rumor_max = 1024+4*((request_delivery_max+2)//3) + request_seal_max = 1024+nip44_length(request_rumor_max) + self.assertLessEqual(request_rumor_max, 65535) + self.assertLessEqual(request_seal_max, 65535) + self.assertLessEqual(1024+nip44_length(request_seal_max), 90000) + max_code = 130+2+4096 + self.assertLessEqual(137+32+2+max_code, 24576) + max_preview = 2+96+8+1+2+256+2+4096+1+4+49152 + self.assertLessEqual(12+max_preview+16, 54000) + self.assertLessEqual(7+((130+2+4096)*8+4)//5+6, 7000) + + def test_safe_sign_literal_framing(self): + # Source-checked literal for draft-10 sections 4.1/4.3 and RFC SignContent; + # not another generator call, nor an independently produced MLS vector. + tbs = bytes.fromhex(V['request_hex'])[:-64] + label = b'\x0dMLS Component\x80\x0e\x07request' + expected = b'\x20MLS 1.0 ' + label + qlen(len(tbs)) + tbs + self.assertEqual(expected.hex(), V['request_sign_content_hex']) + + def test_transition_activation_and_immutable_generation(self): + p = bytes.fromhex(V['component_hex']) + transition(None, p, 'alice', {'alice'}, {'alice'}) + transition(p, p, 'bob', {'alice'}, {'alice'}) + transition(p, b'\0', 'alice', {'alice'}, {'alice'}) + for kwargs in [{'required': False}, {'supported': False}]: + with self.assertRaises(ValueError): + transition(None, p, 'alice', {'alice'}, {'alice'}, **kwargs) + for result in [None, vector(bytes.fromhex(V['entry_hex'])[:-1]+b'\1')]: + with self.assertRaises(ValueError): + transition(p, result, 'alice', {'alice'}, {'alice'}) + with self.assertRaises(ValueError): + transition(p, b'\0', 'bob', {'alice'}, {'alice', 'bob'}) + + def test_transition_departure_and_rotation(self): + p = bytes.fromhex(V['component_hex']) + entry = bytes.fromhex(V['entry_hex']) + fresh_key = ec.derive_private_key(3, ec.SECP256K1()).public_key().public_numbers().x.to_bytes(32, 'big') + fresh = vector(b'\x99'*32 + fresh_key + entry[64:]) + # Self-demotion disables links, then a staying admin can create new ones. + transition(p, b'\0', 'alice', {'alice', 'bob'}, {'bob'}) + with self.assertRaises(ValueError): + transition(p, fresh, 'alice', {'alice', 'bob'}, {'bob'}) + transition(b'\0', fresh, 'bob', {'bob'}, {'bob'}) + # Another admin can demote Alice and create replacement generations. + transition(p, fresh, 'bob', {'alice', 'bob'}, {'bob'}) + # Removing one admin leaf retires links even if its account stays admin. + transition(p, fresh, 'bob', {'alice', 'bob'}, {'alice', 'bob'}, removed_leaf_accounts=['alice']) + for result in [p, vector(b'\x99'*32+entry[32:])]: + with self.assertRaises(ValueError): + transition(p, result, 'bob', {'alice', 'bob'}, {'alice', 'bob'}, removed_leaf_accounts=['alice']) + # Promotion alone preserves a generation. + transition(p, p, 'alice', {'alice'}, {'alice', 'bob'}) + + def test_transition_self_remove_and_terminal_exception(self): + p = bytes.fromhex(V['component_hex']) + transition(p, p, 'charlie', {'bob'}, {'bob'}, self_remove_accounts=['alice']) + with self.assertRaises(ValueError): + transition(p, p, 'bob', {'alice', 'bob'}, {'bob'}, self_remove_accounts=['alice']) + with self.assertRaises(ValueError): + transition(p, b'\0', 'bob', {'bob'}, {'bob'}, self_remove_accounts=['alice']) + with self.assertRaises(ValueError): + transition(p, b'\0', 'alice', {'alice'}, set()) + # Adopted disband does not append an unrelated component update. + transition(p, p, 'alice', {'alice'}, {'alice'}, disband=True) + with self.assertRaises(ValueError): + transition(p, b'\0', 'alice', {'alice'}, {'alice'}, disband=True) + + for actor, admins in [('bob', {'bob'}), ('alice', set()), ('alice', {'alice', 'bob'})]: + with self.assertRaises(ValueError): + transition(p, p, actor, {'alice'}, admins, disband=True) + + def test_registry_and_surface_sync(self): + expected = {'0x800e': 'app-components/group-invite-links-v1.md', + '459': 'transports/nostr-invite-links.md', '460': 'transports/nostr-invite-links.md', + '461': 'transports/nostr-invite-links.md', '30444': 'transports/nostr-invite-links.md'} + registry = (ROOT/'foundation/registries.md').read_text() + layout = (ROOT/'layout.md').read_text() + for value, path in expected.items(): + self.assertIn('`'+value+'`', registry) + self.assertIn('`'+value+'`', (ROOT/path).read_text()) + self.assertIn(Path(path).name, layout) + for path in ['foundation/invite-link-records.md', 'features/group-invite-links.md']: + self.assertIn(Path(path).name, layout) + self.assertIn(Path(path).name, (ROOT/Path(path).parent/'README.md').read_text()) + for path in expected.values(): + self.assertIn(Path(path).name, (ROOT/Path(path).parent/'README.md').read_text()) + idea = (ROOT/'ideas/group-invite-links.md').read_text() + self.assertNotRegex(idea, r'\b(?:MUST|SHOULD|MAY)\b|0x[0-9a-f]+|struct\s*\{') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/vectors/invite-links-v1.json b/tests/vectors/invite-links-v1.json new file mode 100644 index 0000000..1db85b7 --- /dev/null +++ b/tests/vectors/invite-links-v1.json @@ -0,0 +1,23 @@ +{ + "description": "Public synthetic fixtures only; package/event references are illustrative, not MLS or Nostr publications.", + "consent_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "code_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333314137773733a2f2f72656c61792e6578616d706c65", + "code_bech32m": "marmot1qqqhn0nx0muaewav2ksx99wwsu9swq5mlndjmn3gm9vl9q2mzmup0xq3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zy3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenzsfhwumn8ghj7un9d3shjtn90psk6urvv5j9w4kt", + "entry_hex": "111111111111111111111111111111111111111111111111111111111111111179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817989f72ea0cf49536e3c66c787f705186df9a4378083753ae9536d65b3ad7fcddc4aeae6001012fbeacee89f5402a6131b2db9883a7c5151bb9a64663f950805f38000000006b49d20000", + "component_hex": "4089111111111111111111111111111111111111111111111111111111111111111179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817989f72ea0cf49536e3c66c787f705186df9a4378083753ae9536d65b3ad7fcddc4aeae6001012fbeacee89f5402a6131b2db9883a7c5151bb9a64663f950805f38000000006b49d20000", + "request_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc00000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222222222222222222222222222666666666666666666666666666666666666666666666666666666666666666635555555555555555555555555555555555555555555555555555555555555555514137773733a2f2f72656c61792e6578616d706c6558af95f12a062ddd4c0f6bbf412e8a5a9ddacb27a5231fd04d6239101b531894a129c150118301244718b32ff5b4683e0e6678ce4c02f46b205a8f6696256906", + "request_hash": "67c6802c3965a72d7de6c34650c3a75eed5bafbad353c18f570e2c64704dd299", + "request_sign_content_hex": "204d4c5320312e30200d4d4c5320436f6d706f6e656e74800e07726571756573744144000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc00000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222222222222222222222222222666666666666666666666666666666666666666666666666666666666666666635555555555555555555555555555555555555555555555555555555555555555514137773733a2f2f72656c61792e6578616d706c65", + "refresh_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc00000000167c6802c3965a72d7de6c34650c3a75eed5bafbad353c18f570e2c64704dd2992222222222222222222222222222222222222222222222222222222222222222777777777777777777777777777777777777777777777777777777777777777735888888888888888888888888888888888888888888888888888888888888888814137773733a2f2f72656c61792e6578616d706c65cd7fca597c9c9dafc8fc24c8ac94db1c6faeabe43fa871ec6d407f18322bf85c62efede5b5d145e7fbfd3c4aa9b1a411544594cc15223c03d89ba43d8344e600", + "withdrawal_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc0ba2855947e4ff956f3b41b926c7a4fd81bdd9758e7c6292ed8c2c1aefb55472393dcb70d557e9c2853f140b48ffcb453506f4725f2aab04cac59523a85cd9d0d", + "preview_key_hex": "3333333333333333333333333333333333333333333333333333333333333333", + "preview_nonce_hex": "000102030405060708090a0b", + "preview_aad_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817981111111111111111111111111111111111111111111111111111111111111111", + "preview_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111119f72ea0cf49536e3c66c787f705186df9a4378083753ae9536d65b3ad7fcddc4000000006b49d2000009426f6f6b20636c75622f5468757273646179206e69676874732e204272696e6720776861746576657220796f752772652072656164696e672e0000", + "preview_hash": "aeae6001012fbeacee89f5402a6131b2db9883a7c5151bb9a64663f950805f38", + "preview_ciphertext_hex": "e88f77c0c025929c4e8b49c925963238f2272df17fa3477a5807928187afeb5a6d5501d4b3fac8d5517bd51f3d67479e06dddb0a910be2c6c01ce0e2cc8743ddafb8c7388b6fea2003e00a66bb713f71cb17470527d8ac8768713cfe72a53d0206b5c05170c5052a5c6b03d58a84b04c652575ece6f30717c51f1996d4cccc4a0c6250f7fd9b89e3dabf39a376a0dd08a6b77f144a1b009cc03277a023b8b7d18c7f32d9a8b2d149b1aaa7d1d16b82f407602e8afba771", + "status_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc067c6802c3965a72d7de6c34650c3a75eed5bafbad353c18f570e2c64704dd29902acdc4c1b1ccbe28e0baa95b495366e527aca81066345e16d9d57086726ff47bb28699ce6f8bbf14c59c7031ebf82037f7874fb5b5342b96b60db5593fac2eb29", + "grant_record_hex": "1773796e74686574696320707269766174652067726f757000000000000000071111111111111111111111111111111111111111111111111111111111111111004142111111111111111111111111111111111111111111111111111111111111111179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f817989f72ea0cf49536e3c66c787f705186df9a4378083753ae9536d65b3ad7fcddc4aeae6001012fbeacee89f5402a6131b2db9883a7c5151bb9a64663f950805f38000000006b49d2000000000000000000000000000000000000000000000000000000000000000000014097000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333314137773733a2f2f72656c61792e6578616d706c65", + "forward_record_hex": "1773796e74686574696320707269766174652067726f7570000000000000000711111111111111111111111111111111111111111111111111111111111111110141be000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc00000000000000000000000000000000000000000000000000000000000000000000000002222222222222222222222222222222222222222222222222222222222222222666666666666666666666666666666666666666666666666666666666666666635555555555555555555555555555555555555555555555555555555555555555514137773733a2f2f72656c61792e6578616d706c6558af95f12a062ddd4c0f6bbf412e8a5a9ddacb27a5231fd04d6239101b531894a129c150118301244718b32ff5b4683e0e6678ce4c02f46b205a8f6696256906397b226578616d706c65223a22696c6c757374726174697665207369676e6564207075626c69636174696f6e20706c616365686f6c646572227d", + "retired_status_hex": "000179be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f8179811111111111111111111111111111111111111111111111111111111111111114444444444444444444444444444444444444444444444444444444444444444c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee503a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8000000006b3a8fc067c6802c3965a72d7de6c34650c3a75eed5bafbad353c18f570e2c64704dd2990300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/transports/README.md b/transports/README.md index cde4f7e..a94ea2b 100644 --- a/transports/README.md +++ b/transports/README.md @@ -14,6 +14,9 @@ publish and fetch rules, and transport-specific validation. - [quic.md](./quic.md) - experimental raw QUIC binding for transient agent text stream previews (companion to [../features/agent-text-streams-quic.md](../features/agent-text-streams-quic.md)). +The proposed optional [Nostr invite-link extension v1](./nostr-invite-links.md) supplements the primary binding with +codes, encrypted previews and request/admin delivery. It retains the existing MLS group-message and Welcome envelopes. + ## Transport document checklist Each transport document MUST define: diff --git a/transports/nostr-invite-links.md b/transports/nostr-invite-links.md new file mode 100644 index 0000000..8bad82c --- /dev/null +++ b/transports/nostr-invite-links.md @@ -0,0 +1,257 @@ +# Nostr invite-link extension v1 + +Status: proposed; not adopted. This optional extension supplements [nostr.md](nostr.md); it does not change existing +KeyPackage, group-message, or Welcome envelopes. The feature is currently specified for Nostr only. +The associated state and record formats are owned by the +[component](../app-components/group-invite-links-v1.md) and +[record document](../foundation/invite-link-records.md). + +## Allocations + +- Kind `459`: request or withdrawal rumor inside a NIP-59 gift wrap. +- Kind `460`: status rumor inside a NIP-59 gift wrap. +- Kind `461`: private admin-control rumor inside recipient gift wraps, carried in an unsigned MLS app event of the + same kind. The canonical records own the two distinct payload schemas. +- Kind `30444`: signed parameterized-replaceable encrypted preview descriptor. + +These proposed allocations are indexed in [registries.md](../foundation/registries.md#proposed-invite-link-allocations). +The outer kinds `1059` and `13`, NIP-44 encryption, and NIP-59 validation remain upstream primitives. +For every new request, status and admin gift wrap, the seal has empty tags and the outer gift wrap has exactly one +`p` tag with exactly the recipient's lowercase-hex account/inbox key. No extra tags are permitted. The outer author +is the fresh NIP-59 ephemeral key, never an inviter or group identity. +There is no new MLS exporter or account-signature proof class. +All new rumors use exactly the adopted unsigned Nostr-shaped fields and NIP-01 id calculation; no extra JSON members +are accepted. Signed descriptor, seal and gift-wrap objects have those fields plus `sig`. `created_at` is an integer +in `0..9007199254740991`; kinds and tags are the fixed values specified here. This bounds event metadata as well as content. + +## Long code + +```text +struct { + uint16 version; + opaque inbox_pubkey[32]; + opaque link_id[32]; + opaque bearer[32]; + opaque preview_key[32]; + RelayURL request_relays<1..4096>; +} InviteCodeV1; + +struct { + opaque url<1..512>; +} RelayURL; +``` + +`version` is one. Keys and identifiers have the meanings in the component. Bearer and preview key are independently +random 32-byte secrets, distinct from the inbox private key. The list has one through eight unique URLs sorted by +UTF-8 content bytes. The 4096-byte vector ceiling includes each URL's length prefix; eight maximum-length URLs +therefore do not fit. The same aggregate bound applies to `welcome_hints` below. +URLs satisfy the Nostr relay URL profile and MUST use `wss://`; decoder normalization is forbidden. +Local endpoint safety policy MAY refuse a URL without rewriting the code; a syntactically valid URL is not permission +to access a protected local network or send credentials to it. These relays locate the descriptor and request inbox, not the group's delivery stream or recipient account inbox. + +Encode the exact binary bytes with Bech32m using prefix `marmot`. Use BIP-350's checksum constant and standard +eight-to-five-bit conversion with zero padding. Decoding rejects excess/nonzero padding, mixed case, other prefixes, +bad checksums, the Bech32 checksum variant, unknown versions, and malformed binary values. Producers emit lowercase; +decoders accept entirely uppercase forms. This format explicitly permits up to 7000 characters rather than the +generic Bech32 ninety-character limit. Longer strings are invalid before allocation or decoding. +This extended length does not retain [BIP-350's short-address error-detection guarantees](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki#appendix-checksum-design--properties). +The checksum is not authentication. Keep relay coordinates concise and prefer copying complete codes or scanning +fitting QRs over manually transcribing long codes; authenticated descriptor and consent validation still apply. +QR capacity is a separate bound, determined by the chosen version, character mode and error correction level +([capacity reference](https://www.qrcode.com/en/about/version.html)). A producer MUST verify that the complete code fits the chosen QR version and error +correction level before offering that QR. It SHOULD use an entirely uppercase code for QR alphanumeric mode; this +is the same accepted code, not mixed case. Codes near the 7000-character ceiling may not fit any QR. Offer text or +a short URL instead; never truncate a code or alter encoded relay bytes to force it to fit. +It is not an `naddr` or a `nostr:` entity and MUST NOT be passed to an ordinary NIP-19 decoder as either. + +Apps MAY wrap the code in an HTTPS URL fragment or encode it directly in a QR. A fragment is not sent in an ordinary +HTTP request, but page scripts and recipients can read it. Hosts handling a direct-code URL MUST NOT upload the +fragment for resolution or analytics. An outer URL is app routing, not a second protocol encoding. + +The optional short URL maps to this complete code. A host storing it can read all its secrets. Deleting a short URL +does not revoke the code. Short-id alphabet, hostname and lookup protection are deployment policy, not protocol ids. + +## Preview descriptor + +```text +struct { + uint16 version; + opaque inbox_pubkey[32]; + opaque link_id[32]; + opaque bearer_hash[32]; + uint64 expires_at; + uint8 approval_mode; + opaque name<1..256>; + opaque description<0..4096>; + uint8 image_type; + opaque image<0..49152>; +} InvitePreviewV1; +``` + +Version, keys, hashes, mode and expiry have the component's meaning. Text is valid UTF-8 with byte equality and no +normalization. Image types are none zero, JPEG one, and PNG two. None requires empty image bytes; JPEG/PNG require +nonempty image bytes. Unknown types or inconsistent type/length combinations invalidate the record. Before rendering, +clients MUST bound decoded dimensions to at most 1024 by 1024, require the declared JPEG/PNG format, and reject malformed +or unsupported images from the renderer. An image-rendering failure leaves a text-only preview and MUST NOT change the +committed plaintext bytes. No SVG, HTML, external image URL, executable markup, or automatic URL fetch is provided in v1. +Text is displayed as plain text, not interpreted Markdown or HTML. + +`preview_hash = SHA-256(encoded InvitePreviewV1)`. Generate a fresh random twelve-byte nonce for each encryption. +Encrypt the exact plaintext with ChaCha20-Poly1305 using the invitation's independent `preview_key` directly as its +32-byte key. AAD is the canonical binary tuple `uint16(1) || inbox_pubkey[32] || link_id[32]`. +The ciphertext includes the sixteen-byte AEAD tag. Reuse of a nonce with the same key is forbidden. +This key is not an MLS exporter, a NIP-44 conversation key, or an inbox private key. + +The descriptor's Nostr author is `inbox_pubkey`, kind is `30444`, tags are exactly `[["d", lowercase_hex(link_id)]]`, +and content is standard padded base64 of `nonce || ciphertext`. NIP-01 id and signature verification precede +decryption. The total decoded content is bounded to 54000 bytes. The plaintext's inbox, id and bearer hash MUST +match the code, including `SHA-256(code.bearer)`. Its remaining fields are committed by `preview_hash`. +An inbox holder can replace this signed descriptor; a changed preview is not silently substituted for the one the +requester approved. The receiver preserves the exact plaintext seen at consent. + +Fetch from the code's relays with kind/author/`d` filters, apply NIP-01 validation, and use NIP-01 parameterized +replacement order: latest `created_at`, then lowest event id on ties. The timestamp selects a descriptor only; it +never chooses MLS group state or proves a request preceded expiry. +The [feature's tentative Welcome check](../features/group-invite-links.md#admin-review-and-realization) compares +all preview policy fields and the hash before durable join or KeyPackage consumption. A mismatch requires a new +user choice. A match still has the adopted Welcome-bootstrap trust limitation. + +## Request delivery and package evidence + +```text +struct { + opaque key_package_event_id[32]; + RelayURL welcome_hints<0..4096>; +} NostrInviteOfferV1; + +struct { + uint8 operation; + opaque record<1..16384>; + opaque publication<0..12288>; +} NostrInviteRequestV1; +``` + +The offer is the `transport_offer` in the request TBS. `welcome_hints` has zero through eight sorted unique WSS URLs +using the code's RelayURL encoding. They are contextual hints permitted by the existing inbox binding, not account +metadata or evidence of ownership. The event id names one exact signed kind `30443` publication, not its `d` slot. +Fetch it through the account's NIP-65 write-capable set. Verify the event id/signature, publication author, decoded +KeyPackageRef, LeafNode account identity proof, and device signature. Initial ancestor verification can use retained +publication evidence even after it is superseded or expired; admitting a member still requires a currently valid +offer under the adopted KeyPackage selection and lifetime rules. + +Operations are request zero and withdrawal one; their record bytes are exactly InviteRequestV1 and InviteWithdrawalV1. +For a request, `publication` MUST be nonempty and carry the exact signed kind `30443` event named by its offer, in +the canonical evidence encoding below. For withdrawal it MUST be empty. The evidence is outside device-signature +bytes but authenticated by NIP-01 and bound to the signed offer's event id and KeyPackageRef; it cannot substitute +a different offer. Historical evidence can validate ancestry, but never relax current-offer admission checks. +The rumor is kind `459`, has no tags or `sig`, and content is padded base64 of NostrInviteRequestV1. Its pubkey and +the NIP-59 seal author equal the requester account. Gift wraps are addressed to the code's inbox public key. +Requests are published to every usable code relay with independent endpoint outcomes. A first acknowledged NIP-01 +accept is delivery-to-relay evidence only. All initial fanout attempts remain required; failure is retryable. +The requester MUST retain the original record before sending, may rewrap it, and MUST NOT change logical request +identity on a transport retry. Admins query kind `1059` and recipient `p` matching the inbox, then validate every +NIP-59 layer before processing the device-signed record. No recipient filter alone authenticates a sender. +The requester MUST retain each revision's exact authenticated publication evidence with the signed record and +retransmit missing ancestors with their evidence. Sending a refresh includes sending its ancestry as separately +bounded request envelopes; receipt on a relay does not prove an admin retained it. Replacement of a publication +slot or relay deletion therefore does not destroy evidence the requester can resend. + +For request and forwarding evidence, `publication` is a UTF-8 JSON signed kind `30443` event encoded with RFC 8785 +JSON canonicalization. Reject duplicate object keys and non-canonical re-encoding; then apply NIP-01 and existing +KeyPackage publication validation. JSON canonicalization does not replace NIP-01's event-id signing preimage. +Forwarding uses `opaque publication<1..12288>` after the original signed request; it MUST carry the same exact +event bound by that request. A producer MUST check that the complete publication fits the evidence ceiling before +offering that package. A package that cannot fit needs a conforming new offer or a recoverable capacity error. + +## Status delivery + +A kind `460` rumor has no tags or `sig` and content is padded base64 of exactly InviteStatusV1. Its pubkey equals the +admin-account seal author. The gift-wrap recipient is the requester account, never the link inbox. +Use the requester's signed kind `10050` inbox list plus the validated offer's contextual hints under the existing +Welcome binding. An absent, empty, or unavailable list is not permission to guess a default destination. +Clients query their account inbox and authenticate NIP-59 before treating a status as an attributed claim. + +The Welcome itself remains kind `444`, with its existing KeyPackage event `e` tag and group relay metadata. +Request ids, bearers and private request records MUST NOT be added to publicly visible routing tags or KeyPackage +publications. Join correlation uses the encrypted status's Welcome hash and the exact offer's publication reference. +Status transport failure MUST NOT delay valid Welcome delivery or change group membership. + +## Admin delivery inside MLS + +The inner app event carries the [canonical admin batch](../foundation/invite-link-records.md#admin-app-batches). +For a grant, `transport_code` is exactly the binary InviteCodeV1, not Bech32m text or a short URL. Its request relay +vector is retained and forwarded with the grant; verify its id, inbox and bearer against the granted entry and use +its preview key for tentative decryption only after descriptor NIP-01 authentication. Validate the decrypted +plaintext bindings and hash against the granted component entry before displaying or relying on that preview. +For this binding, each `transport_envelope` is a NIP-59 gift-wrap event. Producers split using the canonical recipient +and byte bounds; each logical record is retried independently. JSON is RFC 8785 canonical encoding of a complete NIP-59 gift-wrap event. The outer event has exactly its +NIP-59 recipient `p` tag, naming `recipient_account`; validate the signature and each NIP-59 layer before using it. +The decrypted kind `461` rumor has no tags or `sig`, and content is padded base64 of InviteAdminRecordV1. +Its pubkey and seal author MUST equal the account of the enclosing MLS-authenticated app-event sender. +The inner record's source epoch equals the enclosing MLS application's source epoch. A forwarded nested requester +record retains its own consent signature and account binding; it is not reauthored by the admin. + +The enclosing app event follows the [canonical batch schema](../foundation/invite-link-records.md#admin-app-batches) +and [adopted sender binding](../foundation/application-messages.md#receiver-authentication). +Deliver it by normal MLS/Nostr group messaging, not as a standalone kind `461` relay event. +A parser MUST select the record schema from the authenticated container, not the kind alone: a decrypted rumor +contains an admin record, while the enclosing MLS app event contains a batch. Swapping those bodies is invalid. +The [feature](../features/group-invite-links.md#processing-private-control-records) owns recipient and source/current +admin authorization. Only the addressed recipient opens its copy; ordinary members see recipient accounts but not +the records. Secret or request data in plaintext tags is invalid. + +Fetch and catch-up follow normal group delivery. Transport duplicates do not retire requests. Replay and current +authorization are evaluated by the feature, not by an outer event timestamp. Key grants name one group/generation; +they MUST NOT be accepted merely because an inbox private key decrypts other ciphertext successfully. + +## Relay budgets and failures + +The structural bounds permit events larger than some relays accept. They are receiver safety ceilings, not a promise +that every relay supports them. Before preparing a descriptor, record or batch, producers SHOULD estimate its complete +relay event size after JSON, all nested encryption, base64 and MLS overhead. Use compatible code/group relays, reduce +preview image bytes, and split admin batches within both bounds and endpoint budgets. A single oversized request or +forwarded package evidence cannot be truncated; report a recoverable size/delivery problem or use another conforming +relay. A 54000-byte decoded descriptor can exceed a 64 KiB event budget after base64. +An admin envelope of 90000 bytes expands to roughly 160000 bytes through the app-content and outer-event base64 +layers alone, before MLS and JSON overhead. Splitting a batch cannot make that single envelope fit a smaller relay. + +Relay rejection, size-policy refusal or unavailable endpoint is delivery failure, not a request decision or MLS state +change. Preserve the outstanding exact logical record or prepared MLS obligation, show the actionable delivery error, +and follow existing publish recovery without generating a duplicate Add. This deployment assumption must be tested +with intended relays before enabling the feature. A smaller receiver ceiling needs a future version rather than +silently rejecting conforming bytes as malformed. + +## Limits and privacy + +This v1 profile limits each NIP-44 plaintext to 65535 UTF-8 bytes, including both the rumor and the signed seal +inside a gift wrap. Upstream support for larger extended-length payloads does not raise this feature limit. +Producers MUST check both layers before sending; oversized records are a local capacity error, never truncated. +The admin-record body and publication-evidence limits leave room for JSON, base64 and NIP-44 padding. Receivers +MUST reject gift-wrap JSON above 90000 bytes and enforce the same two decrypted-layer limits. + +Before decoding base64, enforce its encoded-length bound derived from each decoded maximum; reject malformed padding +or alternative alphabets. Re-encoding the decoded bytes as standard padded base64 MUST reproduce the original string; +this also rejects whitespace and nonzero unused pad bits. Unknown record kinds/versions do not fall back to chat or +another invite format. +Receivers MUST limit unauthenticated envelope processing. Admission permits at most 100 open contexts per link, +eight retained refresh revisions per context, and 1000 open-context reservations plus terminal context tombstones +across currently active generations per group. Restoration may exceed admission ceilings only to preserve +already-required recovery facts, as specified by the feature; it blocks new admission rather than deleting those facts. +Retired-generation accounting and still-required recovery facts follow the feature; additional local storage safety +limits MAY refuse new work without deleting required facts. Admission limits do +not grant senders authority to erase other records. Overflow returns a local capacity outcome without claiming the +request was rejected or joined. The feature specifies retention and retries. + +Use bounded per-inbox validation work. After repeated transport failure, exponentially back off with jitter, with +at least one minute between attempts for a context and a maximum backoff of one hour; stop at its signed deadline. Requests MUST NOT be sent automatically merely on opening a preview. +Applications MUST NOT log decrypted requests, bearers, private keys, preview text, or message content. + +Relays can observe inbox addresses, recipient accounts, event sizes and timing. They cannot read protected records. +Publishing a KeyPackage beside a request may correlate the events. A short-link host knows the complete code. +Removing an admin or retiring a key cannot erase their retained copies. Neither MLS nor NIP-59 makes the join anonymous. +QUIC's current agent-stream binding provides no invite-link delivery and MUST NOT be treated as an alternative here. + +## Executable examples + +[Fixture documentation](../tests/README.md) describes the synthetic code, component, request/refresh, withdrawal and +preview vectors. They test canonical and cryptographic boundaries without claiming MLS/Nostr client interoperability. diff --git a/transports/nostr.md b/transports/nostr.md index 3b87ff2..04ebcb4 100644 --- a/transports/nostr.md +++ b/transports/nostr.md @@ -2,6 +2,9 @@ Status: adopted. +The proposed optional [invite-link extension v1](nostr-invite-links.md) owns its additional code, preview and request +envelopes. It does not alter the adopted envelopes or relay discovery rules below. + This document defines the first Marmot transport binding: MLS bytes carried over Nostr relays. Nostr also appears in Marmot identity and app payloads. Those are separate foundation rules: