From 1d66e4e2f6c4eb7573af9dcaea22735c35ef35bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 15:04:59 +0000 Subject: [PATCH] feat(marmot): implement account identity proof v2, the current profile's leaf binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the Marmot resync. A member leaf carries two unrelated keys — the MLS BasicCredential identity, which is the member's Nostr account key, and the MLS leaf signature key MLS generates per device — and MLS never checks that the account agreed to the leaf key next to it. Without a proof, anyone able to author a leaf can claim any account's identity. This is also what makes us classifiable at all. MDK decides Legacy vs Current purely on whether a group requires extension 0xf2f1 or component 0x8009; we required neither, so profile classification errored out before any component check ran. Adds the common authorization-proof envelope (foundation/authorization-proofs.md): 104 fixed-width bytes of signer pubkey, big-endian uint64 timestamp and BIP-340 signature, with event-id reconstruction. The created_at bounds are load-bearing twice over — the lower bound rejects zero, and the upper bound (2^53-1) catches a uint64 whose top bit is set, which reads back negative as a Kotlin Long. Deliberately absent: any comparison of created_at against a local clock. A proof authorizes a long-lived key binding, not a one-time operation, and a wall-clock rule would let skew make two members reach different verdicts on the same Commit. The component itself signs a kind-450 template through NostrSigner rather than raw BIP-340, which is the whole point of the indirection: a NIP-46 bunker or NIP-55 app can produce a proof without exposing arbitrary signing. create() therefore re-verifies everything the signer returned — pubkey, timestamp, kind, tags, content, recomputed id, signature — since an external signer is free to substitute a stale or altered event. Also adds the app-component id registry, and the RFC 9420 signature-scheme mapping to MlsCiphersuite (declared outside the companion: an enum's entries initialize before its companion object, so entry constructor arguments cannot read companion properties). Tested two ways. Sixteen tests pin the spec's published fixture — canonical event serialization, event id, signature, the 104-byte layout — and check that every signed input actually binds, including a ciphersuite change that leaves the signature scheme untouched. Six more validate the proofs in marmot-current-profile.json: those come from a separate implementation, for randomly generated keys, which is the interop property a fixed vector cannot establish. Full quartz marmot suite: 395 tests, 0 failures. Nothing reads or writes these on a real leaf yet — the carrier is the app_data_dictionary, which is Stage 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq --- quartz/plans/2026-09-08-marmot-spec-resync.md | 32 +- quartz/plans/README.md | 2 +- .../marmot/appComponents/AppComponentIds.kt | 110 ++++++ .../AccountIdentityProofV2.kt | 281 +++++++++++++++ .../MarmotAuthorizationProof.kt | 151 ++++++++ .../marmot/mip01Groups/MlsCiphersuite.kt | 39 ++- .../resources/mls/marmot-current-profile.json | 44 +-- .../AccountIdentityProofV2Test.kt | 330 ++++++++++++++++++ .../MarmotCurrentProfileVectorTest.kt | 198 +++++++++++ .../mdk-vector-gen/src/marmot_profile_gen.rs | 4 +- 10 files changed, 1154 insertions(+), 37 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/accountIdentityProof/AccountIdentityProofV2.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AccountIdentityProofV2Test.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotCurrentProfileVectorTest.kt diff --git a/quartz/plans/2026-09-08-marmot-spec-resync.md b/quartz/plans/2026-09-08-marmot-spec-resync.md index 69a812522d..092272ed14 100644 --- a/quartz/plans/2026-09-08-marmot-spec-resync.md +++ b/quartz/plans/2026-09-08-marmot-spec-resync.md @@ -1,6 +1,6 @@ # Marmot: resync against the adopted spec and current MDK -Status: Stage 0 done. Stages 1-7 open. +Status: Stages 0 and 2 done. Stages 1, 3-7 open. Sources checked on 2026-09-08: @@ -281,11 +281,31 @@ and is likely to surface at least the retry behaviour the old patch used to pape (`0x0002`); `AppDataUpdate` proposal (`0x0008`) through `MlsGroup` staging/validation; last-resort as KeyPackage component `0x0004`. Everything else depends on this. -**Stage 2 — account identity proof v2 (`0x8009`).** -`MarmotAuthorizationProof` codec, kind-450 signing template, BIP-340 verify, LeafNode/ -KeyPackage validation, capability advertisement. Ships with the spec's published test vector, -so it can be built and verified before Stage 1 lands. This is what makes us classifiable at -all. +**Stage 2 — account identity proof v2 (`0x8009`). DONE.** + +- `marmot/foundation/authorizationProofs/MarmotAuthorizationProof` — the 104-byte common + envelope from `foundation/authorization-proofs.md`, with the `created_at` bounds (`1` to + `2^53-1`, catching a uint64 that reads back negative as a Long) and event-id reconstruction. + Deliberately no wall-clock comparison: a proof does not expire, because clock skew must not + make two members disagree about the same Commit. +- `marmot/appComponents/accountIdentityProof/AccountIdentityProofV2` — the kind-450 signing + template, production through `NostrSigner` (so NIP-46 / NIP-55 signers work), and validation + returning a typed reason. `create()` re-verifies what the signer returned — pubkey, timestamp, + kind, tags, content, id and signature — because an external signer is free to substitute. +- `marmot/appComponents/AppComponentIds` — the component registry from + `foundation/registries.md`, needed by Stages 1 and 3 too. +- `MlsCiphersuite` gained the RFC 9420 §17.1 signature-scheme mapping, which the proof signs + and validates explicitly. + +Verified: 16 tests against the spec's published fixture (canonical event serialization, event +id, signature, 104-byte layout) plus every signed input's binding, and 6 tests validating the +proofs in `marmot-current-profile.json` — proofs produced by a *separate* implementation for +random keys, which a fixed vector cannot establish. Full `:quartz:jvmTest --tests "*marmot*"`: +395 tests, 0 failures. + +Not yet wired: nothing reads or writes these components on a real leaf. The carrier is the +`app_data_dictionary`, which is Stage 1. Until then this is a correct, tested primitive with no +call sites — which is exactly what makes Stage 1 mechanical rather than exploratory. **Stage 3 — split `MarmotGroupData` into components.** `0x8001` profile, `0x8003` admin-policy, `0x8004` nostr-routing, `0x8002` blossom-image diff --git a/quartz/plans/README.md b/quartz/plans/README.md index 0c4e9c9809..47acb67e2b 100644 --- a/quartz/plans/README.md +++ b/quartz/plans/README.md @@ -10,7 +10,7 @@ _Audited 2026-09-08. 12 plans: 7 shipped (archived), 0 in-progress, 4 queued, 1 | [2026-07-03-incremental-negentropy-storage.md](2026-07-03-incremental-negentropy-storage.md) | Always-current (created_at, id) index so cold NEG-OPENs stop paying a full scan + seal (~340 ms at 50k vs strfry's ~21 ms). | | [2026-07-04-small-req-floor.md](2026-07-04-small-req-floor.md) | Small-REQ dispatch floor: decomposed, inline fast path tried and reverted (no wire-level win); floor is transport-side. | | [2026-08-13-gpu-pow-mining.md](2026-08-13-gpu-pow-mining.md) | GPU NIP-13 mining declined (ARMv8 has SHA-256 in silicon, mobile GPUs do not). Midstate is ~3x on JVM targets; Android hinges on Conscrypt per-digest JNI cost, still unmeasured. created_at refresh while mining shipped. | -| [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stage 0 (interop reference repointed at mdk, current-profile vector generator) done. | +| [2026-09-08-marmot-spec-resync.md](2026-09-08-marmot-spec-resync.md) | Marmot moved off the MIP-era spec (2026-07-02): group state split into `app_data_dictionary` components, account identity proof v2, and a convergence engine. Current MDK rejects our groups outright. Gap analysis + 8-stage plan; Stages 0 (interop reference repointed at mdk, current-profile vector generator) and 2 (account-identity-proof v2) done. | ## Archived (shipped) | Plan | Summary | diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt new file mode 100644 index 0000000000..d785b4a8d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AppComponentIds.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.appComponents + +/** + * Marmot app-component ids, from the spec's `foundation/registries.md`. + * + * App components are the current profile's carrier for application-owned MLS + * state: each id owns the opaque bytes stored under it in an + * `app_data_dictionary`, which can hang off a GroupContext, a LeafNode, a + * KeyPackage, or a GroupInfo. This replaced MIP-01's single monolithic + * `marmot_group_data` extension (0xF2EE), whose fields were split across + * [GROUP_PROFILE_V1], [ADMIN_POLICY_V1], [NOSTR_ROUTING_V1], + * [GROUP_BLOSSOM_IMAGE_V1] and [MESSAGE_RETENTION_V1]. + * + * Ids in `0x0000..0x7fff` are assigned by the MLS extensions draft; Marmot's + * own live in the private-use range `0x8000..0xffff`. A breaking change to a + * component gets a NEW id, never a version field inside the payload — so an + * id is a complete statement of a wire format. + */ +object AppComponentIds { + // ---- upstream, draft-ietf-mls-extensions-10 ---- + + /** `app_components`: the supported (LeafNode) or required (GroupContext) id list. */ + const val APP_COMPONENTS = 0x0001 + + /** `safe_aad`: component-separated framing for MLS `authenticated_data`. */ + const val SAFE_AAD = 0x0002 + + /** + * `last_resort_key_package`: empty-data marker in a KeyPackage's own + * dictionary. Note this is a component, NOT an MLS extension type — the + * MIP-era profile marked last resort with extension `0x000a`, which is now + * the `self_remove` PROPOSAL type. + */ + const val LAST_RESORT_KEY_PACKAGE = 0x0004 + + // ---- Marmot private range ---- + + /** `marmot.group.profile.v1` — name + description. */ + const val GROUP_PROFILE_V1 = 0x8001 + + /** `marmot.group.blossom.image.v1` — encrypted group avatar stored on Blossom. */ + const val GROUP_BLOSSOM_IMAGE_V1 = 0x8002 + + /** `marmot.group.admin-policy.v1` — the active admin account keys. */ + const val ADMIN_POLICY_V1 = 0x8003 + + /** `marmot.transport.nostr.routing.v1` — `nostr_group_id` + the group relay list. */ + const val NOSTR_ROUTING_V1 = 0x8004 + + /** `marmot.group.message-retention.v1` — disappearing-message duration. */ + const val MESSAGE_RETENTION_V1 = 0x8005 + + /** `marmot.group.agent-text-stream.quic.v1`. */ + const val AGENT_TEXT_STREAM_QUIC_V1 = 0x8006 + + /** `marmot.group.avatar-url.v1` — the plain-https alternative to Blossom images. */ + const val GROUP_AVATAR_URL_V1 = 0x8007 + + /** `marmot.group.encrypted-media.v1` — frozen; new groups use [GROUP_ENCRYPTED_MEDIA_V2]. */ + const val GROUP_ENCRYPTED_MEDIA_V1 = 0x8008 + + /** `marmot.member.account-identity-proof.v2` — leaf-only; see `AccountIdentityProofV2`. */ + const val ACCOUNT_IDENTITY_PROOF_V2 = 0x8009 + + /** `marmot.authorization.multi-device-join.v1` — draft. */ + const val MULTI_DEVICE_JOIN_V1 = 0x800a + + /** `marmot.group.encrypted-media.v2`. */ + const val GROUP_ENCRYPTED_MEDIA_V2 = 0x800b + + /** `marmot.group.lifecycle.v1` — active/disbanded. */ + const val GROUP_LIFECYCLE_V1 = 0x800c + + /** + * The `0x` + four-lowercase-hex-digit rendering used wherever a component + * id appears as text: the kind-30443 `app_components` tag values, and the + * `component` tag inside an authorization-proof signing event. + */ + fun toHex(componentId: Int): String { + require(componentId in 0..0xffff) { "component id $componentId is out of the uint16 range" } + val digits = "0123456789abcdef" + return buildString(6) { + append("0x") + append(digits[(componentId shr 12) and 0xf]) + append(digits[(componentId shr 8) and 0xf]) + append(digits[(componentId shr 4) and 0xf]) + append(digits[componentId and 0xf]) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/accountIdentityProof/AccountIdentityProofV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/accountIdentityProof/AccountIdentityProofV2.kt new file mode 100644 index 0000000000..67094a1189 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/accountIdentityProof/AccountIdentityProofV2.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof + +import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds +import com.vitorpamplona.quartz.marmot.foundation.authorizationProofs.MarmotAuthorizationProof +import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `marmot.member.account-identity-proof.v2`, app component `0x8009` + * (spec `app-components/account-identity-proof-v2.md`). + * + * ## What it proves, and why it exists + * + * A Marmot member leaf carries two unrelated keys: the MLS `BasicCredential` + * identity, which is the member's raw 32-byte Nostr account key, and the MLS + * leaf signature key, which is an Ed25519 key MLS generates per device. MLS + * itself never checks that the account named by the credential agreed to the + * leaf key sitting next to it — so without this component, anyone able to + * author a leaf could claim any account's identity. That is what the proof + * closes: the account key signs a statement naming this exact leaf signature + * key, under this exact ciphersuite. + * + * ## Where it lives + * + * In the `app_data_dictionary` of a **LeafNode** — including the LeafNode + * embedded in a KeyPackage. It is invalid in a GroupContext, in a + * KeyPackage-level dictionary, in a GroupInfo, in an `AppEphemeral` proposal, + * or in a SafeAAD item. A GroupContext must *require* `0x8009` in its + * `app_components` list, but must never carry proof data itself. + * + * Consequently the component never changes through `AppDataUpdate`: it is + * created with a new or replacement LeafNode, and disappears only when that + * leaf is removed from the tree. + * + * ## Relationship to v1 + * + * This is a clean break from `marmot.account-identity-proof.v1`, the custom + * MLS extension type `0xf2f1`. There is no fallback and no in-place migration: + * a v2 client rejects a v1-only leaf, and a group that requires `0xf2f1` but + * not `0x8009` is a legacy group outside this profile. MDK classifies groups + * on exactly that distinction, and a group requiring neither is rejected + * outright. + * + * ## Freshness + * + * There is none, deliberately. [MarmotAuthorizationProof.createdAt] is signed + * but never compared against a receiver's clock: the proof authorizes a + * long-lived key binding, not a one-time operation, and a clock-based rule + * would let skew make two members disagree about the same Commit. A proof may + * be reused across KeyPackages and leaves for as long as every signed input + * stays byte-identical; a new leaf signature key, ciphersuite, signature + * scheme, or account identity requires a new proof. + */ +object AccountIdentityProofV2 { + const val COMPONENT_ID = AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2 + const val COMPONENT_NAME = "marmot.member.account-identity-proof.v2" + + /** Local signing template only — clients MUST NOT publish this kind to relays. */ + const val KIND = 450 + + /** + * The fixed proof-domain label in the `d` tag. Note it is + * `marmot.account-identity-proof.v2`, NOT [COMPONENT_NAME]: the spec pins + * the `d` values of proof events as opaque domain labels precisely so they + * are never derived from a component name. + */ + const val D_TAG_VALUE = "marmot.account-identity-proof.v2" + + const val CONTENT = "Authorize this MLS leaf key for my Marmot account" + + /** + * The exact ordered tag array of the proof event. + * + * Every element is signed, so tag order, name, arity and value all have to + * match on both sides or the reconstructed event id differs and + * verification fails. There are no other tags. + */ + fun tags( + ciphersuite: MlsCiphersuite, + mlsSignatureKey: ByteArray, + ): Array> = + arrayOf( + arrayOf("d", D_TAG_VALUE), + arrayOf("component", AppComponentIds.toHex(COMPONENT_ID)), + arrayOf("ciphersuite", ciphersuite.code), + arrayOf("signature_scheme", ciphersuite.signatureScheme), + arrayOf("mls_signature_key", mlsSignatureKey.toHexKey()), + ) + + /** + * The unsigned kind-450 event an account signer is asked to sign. + * + * [mlsSignatureKey] is the LeafNode `signature_key` opaque vector's + * contents WITHOUT its TLS length prefix. + */ + fun signingTemplate( + ciphersuite: MlsCiphersuite, + mlsSignatureKey: ByteArray, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = + EventTemplate( + createdAt = createdAt, + kind = KIND, + tags = tags(ciphersuite, mlsSignatureKey), + content = CONTENT, + ) + + /** + * Ask [signer] to authorize [mlsSignatureKey] for its own account. + * + * The signed event is validated before its signature is extracted, because + * an external signer is free to return something other than what it was + * asked to sign. Anything the signer substituted — a different pubkey, + * timestamp, tag set, content, or a stale cached response — fails here + * rather than becoming a proof that silently authorizes the wrong thing. + */ + suspend fun create( + signer: NostrSigner, + ciphersuite: MlsCiphersuite, + mlsSignatureKey: ByteArray, + createdAt: Long = TimeUtils.now(), + ): MarmotAuthorizationProof { + require(createdAt in 1..MarmotAuthorizationProof.MAX_CREATED_AT) { + "account identity proof created_at must be in 1..${MarmotAuthorizationProof.MAX_CREATED_AT}" + } + val template = signingTemplate(ciphersuite, mlsSignatureKey, createdAt) + val signed: Event = signer.sign(template) + + require(signed.pubKey == signer.pubKey) { + "signer returned an account identity proof event authored by a different account" + } + require(signed.createdAt == createdAt && signed.kind == KIND && signed.content == CONTENT) { + "signer returned a different account identity proof event than requested" + } + require(tagsEqual(signed.tags, template.tags)) { + "signer altered the account identity proof event tags" + } + require( + EventHasher.hashIdCheck( + signed.id, + signed.pubKey, + signed.createdAt, + signed.kind, + signed.tags, + signed.content, + ), + ) { + "signer returned an account identity proof event whose id does not match its fields" + } + + val proof = + MarmotAuthorizationProof( + signerPubKey = signed.pubKey.hexToByteArray(), + createdAt = signed.createdAt, + signature = signed.sig.hexToByteArray(), + ) + require(proof.verifySignatureOver(KIND, template.tags, CONTENT)) { + "signer returned an account identity proof event with an invalid signature" + } + return proof + } + + /** + * Validate a proof taken from a LeafNode dictionary against the rest of + * that leaf. + * + * [credentialIdentity] is the LeafNode `BasicCredential` identity; + * [mlsSignatureKey] its signature key; [ciphersuite] the KeyPackage's + * ciphersuite when validating a KeyPackage, or the group's when validating + * a member leaf. Passing the wrong one is not a benign mismatch — it is + * how a leaf gets bound to the context it is actually used in. + */ + fun validate( + componentData: ByteArray?, + credentialIdentity: ByteArray, + mlsSignatureKey: ByteArray, + ciphersuite: MlsCiphersuite, + ): Result { + if (componentData == null) return Result.MISSING + if (componentData.size != MarmotAuthorizationProof.SIZE) return Result.MALFORMED + val proof = MarmotAuthorizationProof.decodeOrNull(componentData) ?: return Result.MALFORMED + + if (credentialIdentity.size != MarmotAuthorizationProof.PUBKEY_SIZE) { + return Result.CREDENTIAL_IDENTITY_MISMATCH + } + if (!proof.signerPubKey.contentEquals(credentialIdentity)) { + return Result.CREDENTIAL_IDENTITY_MISMATCH + } + if (!proof.verifySignatureOver(KIND, tags(ciphersuite, mlsSignatureKey), CONTENT)) { + return Result.BAD_SIGNATURE + } + return Result.VALID + } + + /** True only for [Result.VALID]; use [validate] when the reason matters. */ + fun isValid( + componentData: ByteArray?, + credentialIdentity: ByteArray, + mlsSignatureKey: ByteArray, + ciphersuite: MlsCiphersuite, + ): Boolean = validate(componentData, credentialIdentity, mlsSignatureKey, ciphersuite) == Result.VALID + + /** + * Why a leaf or KeyPackage was rejected. + * + * The spec lists a longer set of rejection conditions than this enum has + * cases, because several of them are not decidable from the proof bytes + * alone: an absent `0x8009` in the leaf's support list, more than one + * `0x8009` dictionary entry, and the component appearing at an invalid + * location are all properties of the surrounding `app_data_dictionary`. + * Those belong to the dictionary layer and are checked there. + * + * [BAD_SIGNATURE] deliberately absorbs every mismatch in a signed input — + * a wrong ciphersuite, a wrong signature scheme, or a signature over a + * different leaf key all surface identically, because all three mean the + * reconstructed event id was not what the account signed. There is nothing + * to distinguish: the verifier has no way to know which input the signer + * actually used. + */ + enum class Result { + VALID, + + /** No `0x8009` entry in the LeafNode dictionary. */ + MISSING, + + /** Present but not exactly one 104-byte [MarmotAuthorizationProof]. */ + MALFORMED, + + /** `signer_pubkey` is not the LeafNode's `BasicCredential` identity. */ + CREDENTIAL_IDENTITY_MISMATCH, + + /** The BIP-340 signature does not verify over the reconstructed event id. */ + BAD_SIGNATURE, + } + + /** The proof event id, exposed for diagnostics and test vectors. */ + fun proofEventId( + signerPubKey: HexKey, + createdAt: Long, + ciphersuite: MlsCiphersuite, + mlsSignatureKey: ByteArray, + ): ByteArray = EventHasher.hashIdBytes(signerPubKey, createdAt, KIND, tags(ciphersuite, mlsSignatureKey), CONTENT) + + private fun tagsEqual( + a: Array>, + b: Array>, + ): Boolean { + if (a.size != b.size) return false + for (i in a.indices) { + if (!a[i].contentEquals(b[i])) return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt new file mode 100644 index 0000000000..e5c72045b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/foundation/authorizationProofs/MarmotAuthorizationProof.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.foundation.authorizationProofs + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto + +/** + * The common Marmot authorization-proof envelope (spec + * `foundation/authorization-proofs.md`). + * + * A Nostr account key authorizes protocol bytes by signing an ordinary Nostr + * event, and only this 104-byte summary of that event travels in the carrier: + * + * ```text + * struct { + * opaque signer_pubkey[32]; + * uint64 created_at; + * opaque signature[64]; + * } MarmotAuthorizationProof; + * ``` + * + * The indirection through an event exists so an external signer — NIP-46 + * bunker, NIP-55 app — can produce a proof without exposing raw BIP-340 + * signing. Every field is fixed width, so there are no length prefixes and no + * version field: the carrier's component id *is* the format version. + * + * The envelope carries neither the event id nor a copy of the event. A + * verifier rebuilds both from these bytes plus the owning proof class's + * kind/tags/content, which is what binds the signature to a specific + * authority class rather than to "some event this key once signed". + * + * Note what deliberately is NOT here: any comparison of [createdAt] against a + * local clock. A proof does not expire because its timestamp is old. Two + * members validating the same Commit must reach the same answer, and a + * wall-clock rule would let skew fork them. + */ +class MarmotAuthorizationProof( + /** Raw 32-byte x-only secp256k1 account key that signed the proof event. */ + val signerPubKey: ByteArray, + /** Unsigned Unix seconds, `1..MAX_CREATED_AT`. Signed as part of the event. */ + val createdAt: Long, + /** 64-byte BIP-340 signature over the proof event's 32-byte id. */ + val signature: ByteArray, +) { + init { + require(signerPubKey.size == PUBKEY_SIZE) { + "MarmotAuthorizationProof.signer_pubkey must be $PUBKEY_SIZE bytes, was ${signerPubKey.size}" + } + require(signature.size == SIGNATURE_SIZE) { + "MarmotAuthorizationProof.signature must be $SIGNATURE_SIZE bytes, was ${signature.size}" + } + require(createdAt in 1..MAX_CREATED_AT) { + "MarmotAuthorizationProof.created_at must be in 1..$MAX_CREATED_AT, was $createdAt" + } + } + + val signerPubKeyHex: HexKey get() = signerPubKey.toHexKey() + + fun encode(): ByteArray { + val writer = TlsWriter() + writer.putBytes(signerPubKey) + writer.putUint64(createdAt) + writer.putBytes(signature) + return writer.toByteArray() + } + + /** + * Rebuild the proof event's id from this envelope's signer/timestamp plus + * the owning proof class's [kind], [tags] and [content], then check + * [signature] against it. + * + * The caller supplies the class-specific half; getting a tag order, arity + * or value wrong yields a different id and therefore a failed check, which + * is exactly the binding the spec wants. + */ + fun verifySignatureOver( + kind: Int, + tags: Array>, + content: String, + ): Boolean { + val eventId = EventHasher.hashIdBytes(signerPubKeyHex, createdAt, kind, tags, content) + return Nip01Crypto.verify(signature, eventId, signerPubKey) + } + + companion object { + const val PUBKEY_SIZE = 32 + const val SIGNATURE_SIZE = 64 + + /** Exactly 32 + 8 + 64. A carrier entry of any other length is malformed. */ + const val SIZE = PUBKEY_SIZE + 8 + SIGNATURE_SIZE + + /** + * `2^53 - 1`. The spec caps the timestamp here so every JSON + * implementation that touches the signing event represents it exactly — + * a value that survives a round trip through a double is a value two + * clients agree on. + */ + const val MAX_CREATED_AT = 9007199254740991L + + /** + * Decode exactly [SIZE] bytes. Truncation and trailing bytes are both + * rejected: the carrier hands us a component's whole data field, and a + * dictionary entry that is not exactly one proof is malformed, not a + * proof with something appended. + */ + fun decode(bytes: ByteArray): MarmotAuthorizationProof { + require(bytes.size == SIZE) { + "MarmotAuthorizationProof must be exactly $SIZE bytes, was ${bytes.size}" + } + val reader = TlsReader(bytes) + return MarmotAuthorizationProof( + signerPubKey = reader.readBytes(PUBKEY_SIZE), + createdAt = reader.readUint64(), + signature = reader.readBytes(SIGNATURE_SIZE), + ) + } + + /** [decode] without the throw, for validators that report a reason instead. */ + fun decodeOrNull(bytes: ByteArray): MarmotAuthorizationProof? = + try { + decode(bytes) + } catch (_: IllegalArgumentException) { + null + } catch (_: IndexOutOfBoundsException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt index bd800032c8..8c833f9ad7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip01Groups/MlsCiphersuite.kt @@ -20,6 +20,23 @@ */ package com.vitorpamplona.quartz.marmot.mip01Groups +/** + * TLS `SignatureScheme` code points used by the RFC 9420 ciphersuites, as the + * `0x`-prefixed four-digit lowercase hex that Marmot's proof events carry. + * + * These live outside [MlsCiphersuite] rather than in its companion because an + * enum's entries are initialized BEFORE its companion object, so entry + * constructor arguments cannot read companion properties. `const val` in a + * plain object is a compile-time constant and inlines cleanly. + */ +object MlsSignatureScheme { + const val ED25519 = "0x0807" + const val ED448 = "0x0808" + const val ECDSA_SECP256R1_SHA256 = "0x0403" + const val ECDSA_SECP384R1_SHA384 = "0x0503" + const val ECDSA_SECP521R1_SHA512 = "0x0603" +} + /** * MLS ciphersuites defined in RFC 9420 Section 17.1. * Marmot default: MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (0x0001). @@ -28,14 +45,22 @@ enum class MlsCiphersuite( val code: String, val hashAlgorithm: String, val hashOutputBytes: Int, + /** + * The TLS `SignatureScheme` this ciphersuite implies, per RFC 9420 §17.1. + * + * It is redundant with [code] by definition, but Marmot signs it explicitly + * in the account-identity-proof event and validates it there, so the + * mapping has to be first-class rather than inferred at each call site. + */ + val signatureScheme: String, ) { - MLS_128_DHKEMX25519_AES128GCM_SHA256_ED25519("0x0001", "SHA-256", 32), - MLS_128_DHKEMP256_AES128GCM_SHA256_P256("0x0002", "SHA-256", 32), - MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_ED25519("0x0003", "SHA-256", 32), - MLS_256_DHKEMX448_AES256GCM_SHA512_ED448("0x0004", "SHA-512", 64), - MLS_256_DHKEMP521_AES256GCM_SHA512_P521("0x0005", "SHA-512", 64), - MLS_256_DHKEMX448_CHACHA20POLY1305_SHA512_ED448("0x0006", "SHA-512", 64), - MLS_256_DHKEMP384_AES256GCM_SHA384_P384("0x0007", "SHA-384", 48), + MLS_128_DHKEMX25519_AES128GCM_SHA256_ED25519("0x0001", "SHA-256", 32, MlsSignatureScheme.ED25519), + MLS_128_DHKEMP256_AES128GCM_SHA256_P256("0x0002", "SHA-256", 32, MlsSignatureScheme.ECDSA_SECP256R1_SHA256), + MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_ED25519("0x0003", "SHA-256", 32, MlsSignatureScheme.ED25519), + MLS_256_DHKEMX448_AES256GCM_SHA512_ED448("0x0004", "SHA-512", 64, MlsSignatureScheme.ED448), + MLS_256_DHKEMP521_AES256GCM_SHA512_P521("0x0005", "SHA-512", 64, MlsSignatureScheme.ECDSA_SECP521R1_SHA512), + MLS_256_DHKEMX448_CHACHA20POLY1305_SHA512_ED448("0x0006", "SHA-512", 64, MlsSignatureScheme.ED448), + MLS_256_DHKEMP384_AES256GCM_SHA384_P384("0x0007", "SHA-384", 48, MlsSignatureScheme.ECDSA_SECP384R1_SHA384), ; companion object { diff --git a/quartz/src/commonTest/resources/mls/marmot-current-profile.json b/quartz/src/commonTest/resources/mls/marmot-current-profile.json index 990effea03..9c3659a373 100644 --- a/quartz/src/commonTest/resources/mls/marmot-current-profile.json +++ b/quartz/src/commonTest/resources/mls/marmot-current-profile.json @@ -1,31 +1,31 @@ { - "add_commit_public_message": "000100011058b3ff7a7dcea415fd50f694576fe3e500000000000000000100000000000341c101000100010001201d8b71e8aec7159699367f9207331aa22066acaa81159c8b124de4a31ad8a37f20b267d00662eb4bf0f18df0e94d5729a71d67a062b18d16bf8150b9bb3bf9f45a203eb99cd422e76ed22ed3a815d4f02405198c762ceeb9e63dacd54a402f455f1f0001201be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc1102000102000102000602000802000101000000006aa00f7b000000006b0edb8b408600064082408000010d0c00018001800380048009800c00020100800940681be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f10009d497513391541c32fcaf208440252d654c0f67aa8bcc3a58ff9cc6fa0dc9cb9691fbed684e0958b21d2057418e3e70d2c11d17f14278bf82fefa9228b00c6e40400600d62704ab3b3b5361dbf8d5df4f967d265ace3124515acbe4039a901461f503fca8778455bc7641dc12b556cefff19bc424ea2b888e87c96153abb7aa2806070006040300040040401b03b36e266b3584d63b77257a84968b91c81476861d67cc4a12de4107846612edcf4ad9879a7d372ea6aa9d1ea9209206ff47950b760567f0a95a476fbdf00b01205b6f5f83aa775e03e9d483dbd3ba388557e90885676fe8f76a290e99ed6fb02a20d73d15dffb68fea7694378e9e38748e43d11ec5e011e3e981282bbd197d748f9000120defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a3402000102000102000602000802000103203190b1ec03d9b1098905686760b3c3d8106fc77b885a2405408042270894bdda408600064082408000010d0c00018001800380048009800c0002010080094068defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f100725ed9e4a85cc98dd5963095a941d29f4d6de79ab486a196fbe137eea10ad5f9678748b25045d9ecd67e621f4ffc6d42c1d1247e62c3a4f391f105f3d72028944040b34b2073656c6cc38edbbfd94f63d5c89843a6816c980916a839c162200f20a9eaf604e119657a78e7859b72c2259a1861417c42898c09ef353027f5ee72e30a222044de425a5fd03f8a248ad21f5e4689bd1e0f922277cdf5fd48afc1298f1bc83c004040533b06dffddb875a91d0632496c51b7d769724cb98aa2bb15aafaed2bef9a5bfe30f6314dc9af00266d170ce66ba7eb73f64aaf52a5b727e540106e5421e5c0c20242aec20e9523416b9b864e376b91f2acee0d2ed0a637076ab1142c2166a5790209a02103deb0e453262559d75bb30c4ad05cd466f3a1912fef9569fa8d338693d", + "add_commit_public_message": "00010001100efc4b241ee7fa07af1654c4ccd9fb9700000000000000000100000000000341c10100010001000120b7cd97093a3f657f80d4a3cd13d9265de2cea00018af6a94fe9a3e70fe790f5220c8cae80e780b657c863543fa031178a80ebc7a4920e1558d9ac46e4e91e8081420278bcb4e598449abf7cb17bc31eb8f690337bae03bf15a9e41acc19573524e920001201be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc1102000102000102000602000802000101000000006aa01583000000006b0ee193408600064082408000010d0c00018001800380048009800c00020100800940681be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f100cad9be57e90afcfb5c2e25f4f775d33fb3d4bd9a244079a1dcf83f24ee9ceff5309395bf4b50a89af5423634e9ae7397a511e67cca801aa481c98ef08f00e6674040a5f215ab09ba13a5b6017ecadd39907e24f88ace02a5f0df1ada4c83f9602d7830e81013fcf960756ac39ab1efb09677c53d7bea1a54457873a5b08481f32109070006040300040040408e58e000c64f1ca5dbf265acad363000f9eddf00e8c431020511d3909f3a10f3575c9dfe38fab7b8fe5c85f60a4c3afa6a56cf12242067b59418cddcacb9e8070120f1b7731e567b436448e164de55332798ae54591f1f2f125bc0d97c55bd57c07e2048470c71d0897f8ea1d31a83dd07bb11838b761010646e714ae4266f2c5f107b000120defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34020001020001020006020008020001032032b4cd1fdae39def14adf11109afc0f8be9980d8292dba1de751aec268558cb7408600064082408000010d0c00018001800380048009800c0002010080094068defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f10054cc3cba8b570281dbb66f7a3622a6859f134e9722e8bf11875de835a26a84fcd86d2bba926fcd930a85feb3d0cd27b75d64c91a09b89e2364c862c03ad4da7740407bbf1fff92e3bb1691ce70742e7d7c72f7a217359a489c67f5617886828bd33c9c426efd540bf4df4d33d54e39e6b9221aac5685526b2b583e5f8c6ef44bae0422200840c5ccc7c86dd5d63121e495eae0c4be25eb8749eb26d413b835667e5f306b0040409cd267608692a0938b97657594a461211c768d65e5d3bdf46959b1fac5e1f8b28f3a7ec56b2177515e1ffd77a6561a7d303c496aae2583b88f17040d09f0470620d53126c921fbf379202ca70645a85658b887caec6b7c0f6043a2a65486dd2709206faeda4c78238a7d0e25dd40c6d6d3444f4c82b95fd90af168d5f717413e04e8", "app_messages_alice_to_bob": [ { "plaintext": "48656c6c6f20426f6221", - "private_message": "000100021058b3ff7a7dcea415fd50f694576fe3e5000000000000000101001c12c465a57743d0504b0628eb8f4f482bf7eb6c790feed11c25e92457405d2c8fc2a14b0ecfa1117fdb858ba1633847267a556f509fabeef97e22074e299b26286ecc18f52da7a065e046292804a517cd27e9c8ce7e4faa154aedfc065a0c9e24d21187bace68a0ade9bf7b317552a2ee83f31af094f36ab155e357" + "private_message": "00010002100efc4b241ee7fa07af1654c4ccd9fb97000000000000000101001c651142a0f45272b7bb644d08dd14c9feac7655707c3eda67909ba1fb405d228abdb825fb7c19e4c23b114d0f1d2ad8d4fd5c04b32c7b2c9d65f70446f5724531570ac3a8f33119fd3f165789108b1abfd3dffbc54ddafe13a4908025020ce9453556a273d37f219f21fdfe0aa538b7602a66c828c34516161687e4" }, { "plaintext": "5365636f6e64206d65737361676520696e207468652073616d652065706f63682e", - "private_message": "000100021058b3ff7a7dcea415fd50f694576fe3e5000000000000000101001ce42f14d203be2d20c6cb94aa51a0394e4233e7af52df9707e0cfe0fa4074d155bb71e0f30beec4b2aa64f2afb45e2376bc05a4c935f6033d6200be8f42c5b81178238c0db0f925f9a0abeb62fee44d7fbd1bebe14980775d9bcc8566657811f9150c28f86155a2e895a3cced47e6f0b3162c87e588761b029edfe58274e8e09cc0a32bfd9e9fce8498b75a9fd970b71bf4c4" + "private_message": "00010002100efc4b241ee7fa07af1654c4ccd9fb97000000000000000101001cd57df6ee609a8bc9cc3a2d251da43bdfcad7efd0c7f68aeda3d01fa640748cc74fa7b699d54eced431ba1dcfeed72dbc9a2149636ed23c3e965b85d96631105923d64f8dad10b2ed58a5744282604f833c6cfa39988dc91ae1e01effa75671458c3aa92d1ac2c4175b2be59fd795d6d16bf633dcd97ede33771c99bc1f520fe339eaf2050db51600f54e49f5a826c5548aa9" } ], "cipher_suite": 1, "committer": { "account_identity_proof": { - "component": "defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f100725ed9e4a85cc98dd5963095a941d29f4d6de79ab486a196fbe137eea10ad5f9678748b25045d9ecd67e621f4ffc6d42c1d1247e62c3a4f391f105f3d7202894", + "component": "defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f10054cc3cba8b570281dbb66f7a3622a6859f134e9722e8bf11875de835a26a84fcd86d2bba926fcd930a85feb3d0cd27b75d64c91a09b89e2364c862c03ad4da77", "created_at": 1700000000, - "event_id": "8915534faaa884893c2b09d411c4f83232026a1468687002d95073468d800cf0", - "event_json": "[0,\"defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34\",1700000000,450,[[\"d\",\"marmot.account-identity-proof.v2\"],[\"component\",\"0x8009\"],[\"ciphersuite\",\"0x0001\"],[\"signature_scheme\",\"0x0807\"],[\"mls_signature_key\",\"d73d15dffb68fea7694378e9e38748e43d11ec5e011e3e981282bbd197d748f9\"]],\"Authorize this MLS leaf key for my Marmot account\"]", - "signature": "725ed9e4a85cc98dd5963095a941d29f4d6de79ab486a196fbe137eea10ad5f9678748b25045d9ecd67e621f4ffc6d42c1d1247e62c3a4f391f105f3d7202894" + "event_id": "1212428859542925c6826d54d646f4f847097c9a3ab0c81df6820e4c806faeaf", + "event_json": "[0,\"defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34\",1700000000,450,[[\"d\",\"marmot.account-identity-proof.v2\"],[\"component\",\"0x8009\"],[\"ciphersuite\",\"0x0001\"],[\"signature_scheme\",\"0x0807\"],[\"mls_signature_key\",\"48470c71d0897f8ea1d31a83dd07bb11838b761010646e714ae4266f2c5f107b\"]],\"Authorize this MLS leaf key for my Marmot account\"]", + "signature": "54cc3cba8b570281dbb66f7a3622a6859f134e9722e8bf11875de835a26a84fcd86d2bba926fcd930a85feb3d0cd27b75d64c91a09b89e2364c862c03ad4da77" }, "account_pubkey": "defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34", "leaf_dictionary": { "0x0001": "0c00018001800380048009800c", "0x0002": "00", - "0x8009": "defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f100725ed9e4a85cc98dd5963095a941d29f4d6de79ab486a196fbe137eea10ad5f9678748b25045d9ecd67e621f4ffc6d42c1d1247e62c3a4f391f105f3d7202894" + "0x8009": "defdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34000000006553f10054cc3cba8b570281dbb66f7a3622a6859f134e9722e8bf11875de835a26a84fcd86d2bba926fcd930a85feb3d0cd27b75d64c91a09b89e2364c862c03ad4da77" }, - "signer_pub": "d73d15dffb68fea7694378e9e38748e43d11ec5e011e3e981282bbd197d748f9" + "signature_pub": "48470c71d0897f8ea1d31a83dd07bb11838b761010646e714ae4266f2c5f107b" }, "component_ids": { "account_identity_proof_v2": "0x8009", @@ -40,7 +40,7 @@ "description": "Current-profile Marmot group (app_data_dictionary + account-identity-proof v2), built on the OpenMLS extensions-draft fork MDK pins.", "exporters": { "convergence_conformance_v1": { - "commitment": "33c6245e99921e4d122e819d26d6a7d6d1c4888fdb3a560d6d30781afbb78f70", + "commitment": "31ac8c4b984283226bd1be34e0bb0e395cf1a8d446d6ff67257121e73b648141", "context": "636f6e76657267656e63652d636f6e666f726d616e63652d7631", "label": "marmot", "length": 32 @@ -49,7 +49,7 @@ "context": "67726f75702d6576656e74", "label": "marmot", "length": 32, - "secret": "b75b3ceac7a9a9439ee146cbdf84a1305805acefec8d6ffd41c4690d6f9ea574" + "secret": "42e251db82e0775546aed3f687b1196bce518a98c0774cfe6e779f9f69f031e1" } }, "group_state": { @@ -81,26 +81,26 @@ "handshake_wire_format": "public_message", "joiner": { "account_identity_proof": { - "component": "1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f10009d497513391541c32fcaf208440252d654c0f67aa8bcc3a58ff9cc6fa0dc9cb9691fbed684e0958b21d2057418e3e70d2c11d17f14278bf82fefa9228b00c6e", + "component": "1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f100cad9be57e90afcfb5c2e25f4f775d33fb3d4bd9a244079a1dcf83f24ee9ceff5309395bf4b50a89af5423634e9ae7397a511e67cca801aa481c98ef08f00e667", "created_at": 1700000000, - "event_id": "81e5ab834204d79cbeab9d475d7c1f0f74bdbf1e55f321bd4e8bbcb79da4db95", - "event_json": "[0,\"1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11\",1700000000,450,[[\"d\",\"marmot.account-identity-proof.v2\"],[\"component\",\"0x8009\"],[\"ciphersuite\",\"0x0001\"],[\"signature_scheme\",\"0x0807\"],[\"mls_signature_key\",\"3eb99cd422e76ed22ed3a815d4f02405198c762ceeb9e63dacd54a402f455f1f\"]],\"Authorize this MLS leaf key for my Marmot account\"]", - "signature": "09d497513391541c32fcaf208440252d654c0f67aa8bcc3a58ff9cc6fa0dc9cb9691fbed684e0958b21d2057418e3e70d2c11d17f14278bf82fefa9228b00c6e" + "event_id": "abf9bc8bcac31bc1c750cf78faace4d4ff7f9c1ca5087016c4fbc7e07de50025", + "event_json": "[0,\"1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11\",1700000000,450,[[\"d\",\"marmot.account-identity-proof.v2\"],[\"component\",\"0x8009\"],[\"ciphersuite\",\"0x0001\"],[\"signature_scheme\",\"0x0807\"],[\"mls_signature_key\",\"278bcb4e598449abf7cb17bc31eb8f690337bae03bf15a9e41acc19573524e92\"]],\"Authorize this MLS leaf key for my Marmot account\"]", + "signature": "cad9be57e90afcfb5c2e25f4f775d33fb3d4bd9a244079a1dcf83f24ee9ceff5309395bf4b50a89af5423634e9ae7397a511e67cca801aa481c98ef08f00e667" }, "account_pubkey": "1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11", - "encryption_priv": "698980352ccbedd93b48c0dfa8570e25e5910eb5d5990e862fd78e1df7f1c320", - "init_priv": "1c4da405915323129e5d493c780735b836042d57b22704cc5fb7081c4cf1cc1a", - "key_package": "0001000500010001201d8b71e8aec7159699367f9207331aa22066acaa81159c8b124de4a31ad8a37f20b267d00662eb4bf0f18df0e94d5729a71d67a062b18d16bf8150b9bb3bf9f45a203eb99cd422e76ed22ed3a815d4f02405198c762ceeb9e63dacd54a402f455f1f0001201be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc1102000102000102000602000802000101000000006aa00f7b000000006b0edb8b408600064082408000010d0c00018001800380048009800c00020100800940681be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f10009d497513391541c32fcaf208440252d654c0f67aa8bcc3a58ff9cc6fa0dc9cb9691fbed684e0958b21d2057418e3e70d2c11d17f14278bf82fefa9228b00c6e40400600d62704ab3b3b5361dbf8d5df4f967d265ace3124515acbe4039a901461f503fca8778455bc7641dc12b556cefff19bc424ea2b888e87c96153abb7aa2806070006040300040040401b03b36e266b3584d63b77257a84968b91c81476861d67cc4a12de4107846612edcf4ad9879a7d372ea6aa9d1ea9209206ff47950b760567f0a95a476fbdf00b", + "encryption_priv": "af2f5f9d1e3b7492951341e27e826aa93352a33c8cfe7c68dba6d9a6ff823bc5", + "init_priv": "db81a81e9c2f7dec549950c314d81881b3f9206b922ac2da1e3cd06b7c0279bc", + "key_package": "000100050001000120b7cd97093a3f657f80d4a3cd13d9265de2cea00018af6a94fe9a3e70fe790f5220c8cae80e780b657c863543fa031178a80ebc7a4920e1558d9ac46e4e91e8081420278bcb4e598449abf7cb17bc31eb8f690337bae03bf15a9e41acc19573524e920001201be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc1102000102000102000602000802000101000000006aa01583000000006b0ee193408600064082408000010d0c00018001800380048009800c00020100800940681be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f100cad9be57e90afcfb5c2e25f4f775d33fb3d4bd9a244079a1dcf83f24ee9ceff5309395bf4b50a89af5423634e9ae7397a511e67cca801aa481c98ef08f00e6674040a5f215ab09ba13a5b6017ecadd39907e24f88ace02a5f0df1ada4c83f9602d7830e81013fcf960756ac39ab1efb09677c53d7bea1a54457873a5b08481f32109070006040300040040408e58e000c64f1ca5dbf265acad363000f9eddf00e8c431020511d3909f3a10f3575c9dfe38fab7b8fe5c85f60a4c3afa6a56cf12242067b59418cddcacb9e807", "key_package_dictionary": { "0x0004": "" }, "leaf_dictionary": { "0x0001": "0c00018001800380048009800c", "0x0002": "00", - "0x8009": "1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f10009d497513391541c32fcaf208440252d654c0f67aa8bcc3a58ff9cc6fa0dc9cb9691fbed684e0958b21d2057418e3e70d2c11d17f14278bf82fefa9228b00c6e" + "0x8009": "1be68a5a028f2601d0e80d468c344ba331d611b96c358b6032e8b4da0547fc11000000006553f100cad9be57e90afcfb5c2e25f4f775d33fb3d4bd9a244079a1dcf83f24ee9ceff5309395bf4b50a89af5423634e9ae7397a511e67cca801aa481c98ef08f00e667" }, - "signature_priv": "262dcc11eb77d04576b435a4eca5aa1116f7c025587f917684b2ad94166a5007", - "signature_pub": "3eb99cd422e76ed22ed3a815d4f02405198c762ceeb9e63dacd54a402f455f1f" + "signature_priv": "b6fa3e80625315875499f8ea509de3014f315e1e07e9c61e043ffefbdf871614", + "signature_pub": "278bcb4e598449abf7cb17bc31eb8f690337bae03bf15a9e41acc19573524e92" }, "profile": "current", "required_capabilities": { @@ -112,5 +112,5 @@ ] }, "signature_scheme": 2055, - "welcome": "0001000300014098202b385bd9cb7ae93aef0a66f277de147980248243dd008bd5db3477bdb6d79f6a20e7214b81b521f0443d6a688ac1699974b2e50e74acaf0c48ed53635b1d38b173405400b0a4835f6f23bd61579c0ab06687e0c9b6fa8a83c60a77bc3217b0e395026f28bf7f9af4ba5a2e51202956243471cd9968f0416382cc2690a09fecbdcbe17010d120f31df82c6315bba95327c3b6998945a87c44704262452a784e6c4fda66b73fb3c4a536877a0d210d1e88516290d9989015c98432b671bb587976ecb0884c8c942ac3844441c2cd43a1e3cb258c56aff41c10187900b56f46bac2ddfc3ba02f5f81cbecc2c55811976e7033696c474351afddd9301b42cd040112b7760a1cbac8cd59b1b81ae2e46de715cc6920527dabf08c2d2a44b920cb5bf7c6b25c8949463a47aeb9645bc1f957c192daf875d0bbf4ca55d285931a97d43937206889aea6f540741d78b850b8f21db3481be98510ab70b5639c73b7a9d884f3dfa4fbbcdbc871937c887622e69052880d7702d374635190151a4bfbf5839e4491b75880a55337f60bcbabc4446e849717f1e36f53e29d3bd5b9c5031c353bb833ee8f61b7578650782c9c8bf121fd2a9ff1d39798ffdfa0d2e7a890d746ad4ab9416c415441b54e2274cfdf793e3381dd1c765421674e0a315395508a71c3a1dc16d2b2d880a462b278da06c3f0ae3c9e21fc067eb002d3502c3cbf4c0f9d23ac83fba24659c8d87400395048a7def733212d2354c23d4f73ba749dd64ab714218233424f1917bc9ef160a4a0ef58c99d70343c4358c50123dce3d6172352266ecca64741dafc3350299cf775a6db1d0be273fd0581271e54178f51514c493cdf64534e22027cdcf33ea5083fc9d6e20da7e11f2802d89b49a188abc2cdcb5cc06f2f78a2d7539f4294ce53e8e6715e9165fbe2543dd6ffa5b0f0fe7aadbd21e240a9468a43075a3229a974cb1ea30a6cacbbe595532e569b8454792eb37e005b06f5df5cd6c09f48fb44d64370e8f453821a02ce1f16b0ef018c6e800f4947107c7d82ae045659a9b505c359d695ba54228d7801dd1b6b4be2ad71e8a2e272f5fe170413463655150bc826309bf5d1ab44def010179c8e0dcd06066911fd40ca33e7aaad0206f1456abd0e59a77ce9882152f4e4dd05956ade1f42eeae9af62df56dc9d448f80d07bd7c63e9f07ebc779f2c6f137ed622a88b77531e018fa3d54c3be49d27947a3aa61d8f883e550716fda5748505bdbe2151cb73767b092c3d3ba1df53e78d91048ed095f6b8a328e7bdf099cbc31e9ef1106e6b5d6bf5542d55bb9818c4f32a65989f3cd4c21903ac010351436e1dab1564bf2d91cc9afd7577c8920da06584d7f6259b57e7c49cf1df0b4d09743bf15dcc106c131154b2aae7780bebf2163eb0281aeb617974df8762a201ba6ab2d55adb5cef310a9aa2d3c0d0935f1bcb9b85adb60339a3d953c725c543e40a59f01a5bca133248028dcc3b565a101d12124e18b528f2e5a57c044be0cdb88fdb1999d04f81135ee40d4c256f239a8b43a3df62ccafee2e052b5ae2721572f54d3886d9cf16033e0a836a9ac9bb1903ef8083228938702c9b40772031998994c39c7ff081d9e7bcdad82b079ddfe4952ccb17be8ee2d9796530edbc24206fdcc5936c415de287b80c52e743a7dc392d6c0949ab23e4a622c500c51207869c4d24966a9a66dbcf29ae11f9226a5772cc9cddde35f1581a8713fb18d319c4d945af2a2ae42c151dca743b3dc77f6e76d5c76c59ef729b364b6b587eb3e0102036cc781ae67f632e1df0ae5206b0f0f588feae" + "welcome": "00010003000140982088edc6732c65e2bf31918104759c23addb177c45a9d745ecdf77d97a2940ac90201f7b759640231f3d30faa20069f8076b510394b676c23fd904b34f3babf8ae55405413072534ebcdb78a0818f1ddb981a03a81d79b31576990fb66b00308a53cfa8ab94c35da0a613b1ff3fb0ea422c9b75dbb4bc5f975ad049d162330b2e3f3321b81e25ea81b4aad0bddb51e156d5a4f6e60dd34ec4470bd1fdb2f85c6112cf778c4f3c21069ce91b7ef8a3bae3f84bd7aa9e73abe96daaef0856ce6a343bb887e5ec39d31f4caf1eeb8eb5a41644d6e323d0101c6b2dc9459c6cbac5aec90df5f5d7eab0a1c8b274e85bc2a67e5e2f1778a6fbcad7a03bc314f57b7ab547d2159b789e7d88886aaa940c578c9cfbd02722815eb9b6399d639fbf3675985b00cff4ac8b04c380f38f76f4d4175e1497e7b1360088a329e0fbf6e82961df132403590db81524e05e5d2280c829a6f00ed73421d0d0226f169966cf8bf3280aab738464f3a6290c63604dacf038a47f069fc8893850f3c33363723d975674c745948ca4a38a8e36af491768f0579d217fa7b4d21a956ed2c54b68edfd7cc453e7d6e7d34957bd5e1061debe6e6fbb85f1d378005f7a78358f1508bda887ad86da22871e7e3c535d17b47977e019f36e609741532e38295627699d26d5d8d4f43a513f7b1af8b59fe5c4238f47f729d1943dbda69f75491dd684f345f82abbce85fdae6a9e622806d46c45562d6ee3d16691d5dc8acc7cdbe6328c97acdcc2d72fafe2c07c84d4223a44ae56d20794951eee0f65c62bc14a0444238afcc757ad05e55dab9ae49342d718d6b2454f10b39511f991bea541fbe58271e7591d258f8191f8c09d2a6e102209d618a65a0c3d274df76c3efd6614e3eaf229e6e2441246de624beeac4726296d5551e5476edd616835edddf7631b0e2ecb4bf42b5a5ca5c05bed1eda6a326545f3699eb06d50c20a37a5d5b6b9c151672efec40a8f9434a5105ac0d44d1df5f50258dcc71abb0ccda6abfa953c2a7b9cb660838fcc0c39da02c002129dab4aaf91177ba5b83b0a9d566921d4f97339f682a580c0391ad6dc5642149b4fc3692781892ac83a91b87f7d2fdba31c9a4a69438c21ff6f7daa16605a616360dbe2450772a5c7bfb868d87b10eacf3aa293b915def15f0a76d4912b12c06f37d5c9479c3aabdd8a7a30b7ae395cbdee4f411963c7d1ff485d74a201474399ac6d0fda3f08fe530a1538811532c8478aa745067120b3bf9233a81dbcf8007cf862313e50b475757b104de5fff1b0a72ea4f6c7cfda137b160fef53293e4365106e81a6d281beb5b54edb9420d6a98df5086bdeab25cba91f828b44a0f072db0ba411111f9cea5f642bbba89fe046dc728678967c2ee683e697459ee7e7c9a8798d4b29288c15d8541dd70d32d0b57258716b233f13a9df7b450a16f493c82b6076fd3b62a0407864209818ca9e4cd719a1c7b5333c54541914750c8f9048bbe9f93903f69809bb22416b6174e8cb435d3c7823afc9df27a8355cbc668871c7373000dded2ab0c0a41a7bbd433ecde689d014f42b33906983092ae5abfa1f5c70540d25ed7d8b4b34e3a55cecb8dd6b0e012d3df468c1bed55edded0c6eccf3c0a45e4a67c361db654041b3639fb4ba195270918744555ea69d27a1f4654b7b37f7a8da1d1b4323b17c9fdd24b098068e88d839fcb44aedaca36b01946663eb3fbab12023da1e6f9c0d27a34df95eeeed4d0791b7d4180faef0c028ec965400ce384456f66694bb0399fc568097f8e0d12f1069f7d8c9aabb2b65aed2591954b6a2e" } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AccountIdentityProofV2Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AccountIdentityProofV2Test.kt new file mode 100644 index 0000000000..f5117d3ddf --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/AccountIdentityProofV2Test.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.appComponents + +import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 +import com.vitorpamplona.quartz.marmot.foundation.authorizationProofs.MarmotAuthorizationProof +import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `marmot.member.account-identity-proof.v2` against the fixed vector published + * in the spec (`app-components/account-identity-proof-v2.md`, "Signing test + * vector"). + * + * This vector is the whole reason Stage 2 could be built before the + * `app_data_dictionary` carrier exists: it pins the canonical event + * serialization, the event id, the BIP-340 signature and the 104-byte + * component layout independently of any MLS plumbing. If these pass, a proof + * we emit is one MDK accepts. + */ +class AccountIdentityProofV2Test { + // BIP-340 secret key 3 — test material from the spec, never a real key. + private val accountPubKey = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + private val mlsSignatureKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".hexToByteArray() + private val createdAt = 1700000000L + private val expectedEventId = "b7e9a15dd85990fb0f49c33db3cc9875f73986207b038404ceb6b7fec4e0af6b" + private val expectedSignature = + "c5315d3c85b9d4907cb03395a2a97b3ba2eab393f8e45b13a5d5233acedac60a" + + "51d2a295e1b1b5ee372d18a49bdb8041a7dba9dedce722c7c6f712f78bbdfb5d" + private val expectedComponent = + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + + "000000006553f100" + + expectedSignature + + private val ciphersuite = MlsCiphersuite.DEFAULT + + private fun specProof() = + MarmotAuthorizationProof( + signerPubKey = accountPubKey.hexToByteArray(), + createdAt = createdAt, + signature = expectedSignature.hexToByteArray(), + ) + + @Test + fun specVectorTagsAreExactAndOrdered() { + val tags = AccountIdentityProofV2.tags(ciphersuite, mlsSignatureKey) + assertEquals(5, tags.size, "the proof event has exactly five tags") + assertContentEquals(arrayOf("d", "marmot.account-identity-proof.v2"), tags[0]) + assertContentEquals(arrayOf("component", "0x8009"), tags[1]) + assertContentEquals(arrayOf("ciphersuite", "0x0001"), tags[2]) + assertContentEquals(arrayOf("signature_scheme", "0x0807"), tags[3]) + assertContentEquals( + arrayOf("mls_signature_key", "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"), + tags[4], + ) + } + + @Test + fun specVectorEventId() { + assertEquals( + expectedEventId, + AccountIdentityProofV2 + .proofEventId(accountPubKey, createdAt, ciphersuite, mlsSignatureKey) + .toHexKey(), + ) + } + + @Test + fun specVectorComponentBytes() { + assertEquals(MarmotAuthorizationProof.SIZE, specProof().encode().size) + assertEquals(expectedComponent, specProof().encode().toHexKey()) + } + + @Test + fun specVectorComponentRoundTrips() { + val decoded = MarmotAuthorizationProof.decode(expectedComponent.hexToByteArray()) + assertEquals(accountPubKey, decoded.signerPubKeyHex) + assertEquals(createdAt, decoded.createdAt) + assertEquals(expectedSignature, decoded.signature.toHexKey()) + assertContentEquals(expectedComponent.hexToByteArray(), decoded.encode()) + } + + @Test + fun specVectorValidates() { + assertEquals( + AccountIdentityProofV2.Result.VALID, + AccountIdentityProofV2.validate( + componentData = expectedComponent.hexToByteArray(), + credentialIdentity = accountPubKey.hexToByteArray(), + mlsSignatureKey = mlsSignatureKey, + ciphersuite = ciphersuite, + ), + ) + } + + // --- every signed input actually binds ------------------------------------ + + @Test + fun aDifferentLeafSignatureKeyFailsVerification() { + val otherLeafKey = ByteArray(32) { 0x7f } + assertEquals( + AccountIdentityProofV2.Result.BAD_SIGNATURE, + AccountIdentityProofV2.validate( + expectedComponent.hexToByteArray(), + accountPubKey.hexToByteArray(), + otherLeafKey, + ciphersuite, + ), + "the proof must not carry over to a different MLS leaf key", + ) + } + + @Test + fun aDifferentCiphersuiteFailsVerification() { + // 0x0003 shares Ed25519 with 0x0001, so only the `ciphersuite` tag + // differs — the narrowest possible way to get this wrong. + assertEquals( + AccountIdentityProofV2.Result.BAD_SIGNATURE, + AccountIdentityProofV2.validate( + expectedComponent.hexToByteArray(), + accountPubKey.hexToByteArray(), + mlsSignatureKey, + MlsCiphersuite.MLS_128_DHKEMX25519_CHACHA20POLY1305_SHA256_ED25519, + ), + "the ciphersuite is a signed input even when the signature scheme is unchanged", + ) + } + + @Test + fun aDifferentCredentialIdentityIsRejectedBeforeCrypto() { + assertEquals( + AccountIdentityProofV2.Result.CREDENTIAL_IDENTITY_MISMATCH, + AccountIdentityProofV2.validate( + expectedComponent.hexToByteArray(), + ByteArray(32) { 0x01 }, + mlsSignatureKey, + ciphersuite, + ), + ) + } + + @Test + fun missingAndMalformedComponents() { + assertEquals( + AccountIdentityProofV2.Result.MISSING, + AccountIdentityProofV2.validate(null, accountPubKey.hexToByteArray(), mlsSignatureKey, ciphersuite), + ) + val bytes = expectedComponent.hexToByteArray() + assertEquals( + AccountIdentityProofV2.Result.MALFORMED, + AccountIdentityProofV2.validate( + bytes.copyOf(bytes.size - 1), + accountPubKey.hexToByteArray(), + mlsSignatureKey, + ciphersuite, + ), + "a truncated component is malformed", + ) + assertEquals( + AccountIdentityProofV2.Result.MALFORMED, + AccountIdentityProofV2.validate( + bytes + 0x00, + accountPubKey.hexToByteArray(), + mlsSignatureKey, + ciphersuite, + ), + "trailing bytes are malformed, not an appended proof", + ) + } + + @Test + fun aFlippedSignatureBitFails() { + val tampered = expectedComponent.hexToByteArray() + tampered[tampered.size - 1] = (tampered[tampered.size - 1].toInt() xor 0x01).toByte() + assertEquals( + AccountIdentityProofV2.Result.BAD_SIGNATURE, + AccountIdentityProofV2.validate( + tampered, + accountPubKey.hexToByteArray(), + mlsSignatureKey, + ciphersuite, + ), + ) + } + + // --- envelope bounds ------------------------------------------------------ + + @Test + fun createdAtZeroIsRejected() { + assertFailsWith("created_at 0 is never a valid signing timestamp") { + MarmotAuthorizationProof( + accountPubKey.hexToByteArray(), + 0L, + expectedSignature.hexToByteArray(), + ) + } + } + + @Test + fun createdAtAboveTheJsonSafeIntegerIsRejected() { + assertFailsWith { + MarmotAuthorizationProof( + accountPubKey.hexToByteArray(), + MarmotAuthorizationProof.MAX_CREATED_AT + 1, + expectedSignature.hexToByteArray(), + ) + } + } + + @Test + fun aCreatedAtWithTheTopBitSetDecodesAsOutOfRange() { + // uint64 on the wire, Long in Kotlin: a value past 2^63 reads back + // negative, which the bounds check has to catch rather than wrap. + val hostile = + accountPubKey.hexToByteArray() + + ByteArray(8) { 0xff.toByte() } + + expectedSignature.hexToByteArray() + assertEquals(MarmotAuthorizationProof.SIZE, hostile.size) + assertNull(MarmotAuthorizationProof.decodeOrNull(hostile)) + assertEquals( + AccountIdentityProofV2.Result.MALFORMED, + AccountIdentityProofV2.validate( + hostile, + accountPubKey.hexToByteArray(), + mlsSignatureKey, + ciphersuite, + ), + ) + } + + // --- production round trip ------------------------------------------------ + + @Test + fun createThenValidateRoundTrip() = + runBlocking { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + val leafKey = ByteArray(32) { it.toByte() } + + val proof = AccountIdentityProofV2.create(signer, ciphersuite, leafKey, createdAt) + + assertEquals(signer.pubKey, proof.signerPubKeyHex) + assertEquals(createdAt, proof.createdAt) + assertEquals( + AccountIdentityProofV2.Result.VALID, + AccountIdentityProofV2.validate( + proof.encode(), + keyPair.pubKey, + leafKey, + ciphersuite, + ), + ) + } + + @Test + fun aProofDoesNotCarryToAnotherAccount() = + runBlocking { + val leafKey = ByteArray(32) { it.toByte() } + val alice = KeyPair() + val bob = KeyPair() + val proof = AccountIdentityProofV2.create(NostrSignerInternal(alice), ciphersuite, leafKey, createdAt) + + assertNotEquals(alice.pubKey.toHexKey(), bob.pubKey.toHexKey()) + assertEquals( + AccountIdentityProofV2.Result.CREDENTIAL_IDENTITY_MISMATCH, + AccountIdentityProofV2.validate(proof.encode(), bob.pubKey, leafKey, ciphersuite), + "Alice's proof must not authenticate a leaf claiming to be Bob", + ) + } + + @Test + fun reuseIsAllowedOnlyWhileEverySignedInputIsIdentical() = + runBlocking { + val signer = NostrSignerInternal(KeyPair()) + val leafKey = ByteArray(32) { it.toByte() } + val first = AccountIdentityProofV2.create(signer, ciphersuite, leafKey, createdAt) + val second = AccountIdentityProofV2.create(signer, ciphersuite, leafKey, createdAt) + + // Same inputs, same signed event: either proof validates for the + // other's leaf. (BIP-340 signatures need not be byte-identical, so + // compare behaviour rather than bytes.) + assertTrue( + AccountIdentityProofV2.isValid(first.encode(), signer.pubKey.hexToByteArray(), leafKey, ciphersuite), + ) + assertTrue( + AccountIdentityProofV2.isValid(second.encode(), signer.pubKey.hexToByteArray(), leafKey, ciphersuite), + ) + + val rotatedLeafKey = ByteArray(32) { (it + 1).toByte() } + assertEquals( + AccountIdentityProofV2.Result.BAD_SIGNATURE, + AccountIdentityProofV2.validate( + first.encode(), + signer.pubKey.hexToByteArray(), + rotatedLeafKey, + ciphersuite, + ), + "rotating the MLS leaf key requires a fresh proof", + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotCurrentProfileVectorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotCurrentProfileVectorTest.kt new file mode 100644 index 0000000000..3fbfd200f8 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotCurrentProfileVectorTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.marmot.appComponents + +import com.vitorpamplona.quartz.TestResourceLoader +import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 +import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Validates the account-identity proofs in `marmot-current-profile.json`, + * generated by `quartz/tools/mdk-vector-gen`'s `marmot-profile-gen` against the + * OpenMLS fork MDK builds on. + * + * `AccountIdentityProofV2Test` proves we match the spec's published fixture. + * This proves we match proofs produced by a *separate implementation* of that + * spec, for randomly generated keys — which is the property that actually + * matters for interop, and the one a fixed vector cannot establish. + * + * Everything else this vector carries (the `app_data_dictionary` framing, the + * Welcome, the Add commit) is Stage 1/3 work; the assertions here stay on the + * proof component and the registry ids so the test is meaningful today rather + * than aspirational. + */ +class MarmotCurrentProfileVectorTest { + @Serializable + private data class Proof( + val component: String, + @SerialName("created_at") val createdAt: Long, + @SerialName("event_id") val eventId: String, + val signature: String, + ) + + @Serializable + private data class Party( + @SerialName("account_pubkey") val accountPubKey: String, + @SerialName("signature_pub") val signaturePub: String, + @SerialName("leaf_dictionary") val leafDictionary: Map, + @SerialName("account_identity_proof") val proof: Proof, + ) + + @Serializable + private data class ComponentIds( + @SerialName("app_components") val appComponents: String, + @SerialName("safe_aad") val safeAad: String, + @SerialName("last_resort_key_package") val lastResort: String, + @SerialName("group_profile_v1") val groupProfile: String, + @SerialName("admin_policy_v1") val adminPolicy: String, + @SerialName("nostr_routing_v1") val nostrRouting: String, + @SerialName("account_identity_proof_v2") val accountIdentityProof: String, + @SerialName("group_lifecycle_v1") val groupLifecycle: String, + ) + + @Serializable + private data class Vector( + @SerialName("cipher_suite") val cipherSuite: Int, + @SerialName("signature_scheme") val signatureScheme: Int, + val profile: String, + @SerialName("component_ids") val componentIds: ComponentIds, + val committer: Party, + val joiner: Party, + ) + + private val vector: Vector = + JsonMapper.jsonInstance.decodeFromString( + TestResourceLoader().loadString("mls/marmot-current-profile.json"), + ) + + private val ciphersuite = MlsCiphersuite.DEFAULT + + private fun assertProofHolds( + label: String, + party: Party, + ) { + val leafKey = party.signaturePub.hexToByteArray() + + assertEquals( + party.proof.eventId, + AccountIdentityProofV2 + .proofEventId(party.accountPubKey, party.proof.createdAt, ciphersuite, leafKey) + .toHexKey(), + "$label: our kind-450 event id must match the generator's", + ) + + assertEquals( + AccountIdentityProofV2.Result.VALID, + AccountIdentityProofV2.validate( + componentData = party.proof.component.hexToByteArray(), + credentialIdentity = party.accountPubKey.hexToByteArray(), + mlsSignatureKey = leafKey, + ciphersuite = ciphersuite, + ), + "$label: externally generated proof must validate", + ) + + val fromDictionary = party.leafDictionary[AppComponentIds.toHex(AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2)] + assertNotNull(fromDictionary, "$label: leaf dictionary must carry a 0x8009 entry") + assertEquals( + party.proof.component, + fromDictionary, + "$label: the 0x8009 dictionary entry is the proof component verbatim", + ) + } + + @Test + fun theVectorIsACurrentProfileGroupOnOurDefaultCiphersuite() { + assertEquals("current", vector.profile) + assertEquals(ciphersuite.code, "0x${vector.cipherSuite.toString(16).padStart(4, '0')}") + assertEquals(ciphersuite.signatureScheme, "0x${vector.signatureScheme.toString(16).padStart(4, '0')}") + } + + @Test + fun ourRegistryIdsMatchTheGeneratorRegistry() { + val ids = vector.componentIds + assertEquals(ids.appComponents, AppComponentIds.toHex(AppComponentIds.APP_COMPONENTS)) + assertEquals(ids.safeAad, AppComponentIds.toHex(AppComponentIds.SAFE_AAD)) + assertEquals(ids.lastResort, AppComponentIds.toHex(AppComponentIds.LAST_RESORT_KEY_PACKAGE)) + assertEquals(ids.groupProfile, AppComponentIds.toHex(AppComponentIds.GROUP_PROFILE_V1)) + assertEquals(ids.adminPolicy, AppComponentIds.toHex(AppComponentIds.ADMIN_POLICY_V1)) + assertEquals(ids.nostrRouting, AppComponentIds.toHex(AppComponentIds.NOSTR_ROUTING_V1)) + assertEquals(ids.accountIdentityProof, AppComponentIds.toHex(AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2)) + assertEquals(ids.groupLifecycle, AppComponentIds.toHex(AppComponentIds.GROUP_LIFECYCLE_V1)) + } + + @Test + fun theCommitterProofValidates() = assertProofHolds("committer", vector.committer) + + @Test + fun theJoinerProofValidates() = assertProofHolds("joiner", vector.joiner) + + @Test + fun theTwoProofsAreNotInterchangeable() { + // Different accounts AND different leaf keys, so this catches a + // validator that ignored either binding. + assertTrue(vector.committer.accountPubKey != vector.joiner.accountPubKey) + assertTrue(vector.committer.signaturePub != vector.joiner.signaturePub) + + assertEquals( + AccountIdentityProofV2.Result.CREDENTIAL_IDENTITY_MISMATCH, + AccountIdentityProofV2.validate( + vector.committer.proof.component + .hexToByteArray(), + vector.joiner.accountPubKey.hexToByteArray(), + vector.joiner.signaturePub.hexToByteArray(), + ciphersuite, + ), + ) + assertEquals( + AccountIdentityProofV2.Result.BAD_SIGNATURE, + AccountIdentityProofV2.validate( + vector.committer.proof.component + .hexToByteArray(), + vector.committer.accountPubKey.hexToByteArray(), + vector.joiner.signaturePub.hexToByteArray(), + ciphersuite, + ), + ) + } + + @Test + fun everyLeafDictionaryAdvertisesTheProofAndSafeAad() { + // The dictionary decoder is Stage 1; assert the entry set here so a + // regression in what the generator emits is caught early. + for ((label, party) in listOf("committer" to vector.committer, "joiner" to vector.joiner)) { + assertEquals( + setOf("0x0001", "0x0002", "0x8009"), + party.leafDictionary.keys, + "$label: a member leaf carries the supported list, safe_aad, and the proof", + ) + } + } +} diff --git a/quartz/tools/mdk-vector-gen/src/marmot_profile_gen.rs b/quartz/tools/mdk-vector-gen/src/marmot_profile_gen.rs index e896ab8ab1..1f8dbcf8b4 100644 --- a/quartz/tools/mdk-vector-gen/src/marmot_profile_gen.rs +++ b/quartz/tools/mdk-vector-gen/src/marmot_profile_gen.rs @@ -528,7 +528,9 @@ fn main() { }, "committer": { "account_pubkey": hex::encode(alice.account_xonly), - "signer_pub": hex::encode(alice.signer.public()), + // Same key name as the joiner's: both are that member's MLS leaf + // signature public key, and one concept gets one name. + "signature_pub": hex::encode(alice.signer.public()), "leaf_dictionary": alice_leaf_dict, "account_identity_proof": { "component": hex::encode(&alice_proof.component),