mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
feat(marmot): implement account identity proof v2, the current profile's leaf binding
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+110
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
+281
@@ -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<Array<String>> =
|
||||
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<Event> =
|
||||
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<Array<String>>,
|
||||
b: Array<Array<String>>,
|
||||
): Boolean {
|
||||
if (a.size != b.size) return false
|
||||
for (i in a.indices) {
|
||||
if (!a[i].contentEquals(b[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+151
@@ -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<Array<String>>,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-7
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+330
@@ -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<IllegalArgumentException>("created_at 0 is never a valid signing timestamp") {
|
||||
MarmotAuthorizationProof(
|
||||
accountPubKey.hexToByteArray(),
|
||||
0L,
|
||||
expectedSignature.hexToByteArray(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createdAtAboveTheJsonSafeIntegerIsRejected() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+198
@@ -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<String, String>,
|
||||
@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<Vector>(
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user