@coordination-network/identity 0.0.2 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DERIVATION.md CHANGED
@@ -1,8 +1,8 @@
1
- # Key derivation scheme `v1` — normative specification
1
+ # Key derivation scheme `v2` — normative specification
2
2
 
3
3
  This document is the normative, spec-liftable description of `@coordination-network/identity`'s key derivation scheme. It is written to be read independently of the source: every formula, constant, and edge case a compliant reimplementation needs is here. Where this document and the source (`src/`) disagree, treat that as a bug in one of the two — they must never diverge.
4
4
 
5
- Everything below is versioned as **`v1`** (`KEY_DERIVATION_VERSION`). The core of the scheme is **all-ECDSA (secp256k1)**: account, spending, viewing, and auth (§3–§5) are the complete secp256k1 core, unchanged since §7's original ECDSA-only decision. `v1` additionally defines one explicitly-versioned, additive Baby Jubjub identity key layered on top of that secp256k1 core; see [§7](#7-team-decision-ecdsa-only-no-eddsa) for the history and [§12](#12-baby-jubjub-identity-key-and-semaphore-commitment) for its normative definition.
5
+ Everything below is versioned as **`v2`** (`KEY_DERIVATION_VERSION`). The core of the scheme is **all-ECDSA (secp256k1)**: account, spending, viewing, and auth (§3–§5) are the complete secp256k1 core, unchanged since §7's original ECDSA-only decision. `v2` additionally defines one explicitly-versioned, additive Baby Jubjub identity key layered on top of that secp256k1 core; see [§7](#7-team-decision-ecdsa-only-no-eddsa) for the history and [§12](#12-baby-jubjub-identity-key-and-semaphore-commitment) for its normative definition.
6
6
 
7
7
  ## 0. Notation
8
8
 
@@ -52,7 +52,7 @@ The 32-byte scalar is expanded (`scalarToKeypair`, `src/keypair.ts`) into a full
52
52
  - `publicKey` — the 33-byte SEC1 **compressed** public key (`getPublicKey(privateKey, true)`).
53
53
  - `uncompressedPublicKey` — the 65-byte SEC1 **uncompressed** public key, `0x04 || X || Y` (`getPublicKey(privateKey, false)`).
54
54
 
55
- This is the root key of the `v1` model: the `networkId` (§6) and the derivation-message signature (§4, whose halves seed spending/viewing) are both computed from it, and nothing downstream of it needs the device secret again.
55
+ This is the root key of the `v2` model: the `networkId` (§6) and the derivation-message signature (§4, whose halves seed spending/viewing) are both computed from it, and nothing downstream of it needs the device secret again.
56
56
 
57
57
  ## 4. Derivation-message signature, and the spending/viewing keys
58
58
 
@@ -73,9 +73,9 @@ sig = ECDSA-sign(digest, accountPrivKey) # secp256k1, RFC-6979 deterministic,
73
73
  sig64 = compact(sig) # 64 bytes: r (32) || s (32)
74
74
  ```
75
75
 
76
- - **RFC-6979 deterministic**: the nonce `k` is derived deterministically from the private key and message, so `signDerivationMessage` is a pure function of `accountPrivKey` — no randomness anywhere in the `v1` chain after `generateDeviceSecret`.
76
+ - **RFC-6979 deterministic**: the nonce `k` is derived deterministically from the private key and message, so `signDerivationMessage` is a pure function of `accountPrivKey` — no randomness anywhere in the `v2` chain after `generateDeviceSecret`.
77
77
  - **`lowS: true`**: the signature's `s` is normalized to the lower half of `[0, n)`, matching Ethereum's canonical signature malleability rule.
78
- - **No `extraEntropy`**: noble's `sign` supports optional auxiliary randomization of the nonce; `v1` explicitly disables it, because introducing any entropy here would break determinism (the same `deviceSecret` must always yield the same `sig64`, forever).
78
+ - **No `extraEntropy`**: noble's `sign` supports optional auxiliary randomization of the nonce; `v2` explicitly disables it, because introducing any entropy here would break determinism (the same `deviceSecret` must always yield the same `sig64`, forever).
79
79
  - The compact encoding is `r || s` (64 raw bytes, no recovery byte / `v`) — `sig.toCompactRawBytes()`.
80
80
 
81
81
  `sig64` is the single piece of entropy that both the spending/viewing keys (§4.3) and the auth key (§5) are derived from; it is never itself persisted — it is recomputed from `accountPrivKey` (equivalently, from the device secret) whenever needed.
@@ -104,23 +104,26 @@ authPrivKey = hashToScalar(DOMAIN_AUTH, sig64)
104
104
 
105
105
  where `DOMAIN_AUTH = "coordination-network/identity/v1/auth"`. Unlike spending/viewing, the auth key _does_ go through the full `hashToScalar` reject-and-retry construction (§2) — it has no external compatibility contract to preserve, so it uses the scheme's standard primitive rather than a bespoke one.
106
106
 
107
- ## 6. `networkId`
107
+ ## 6. `networkId` and `memberNetworkId`
108
108
 
109
109
  ```
110
- networkId = keccak256(X || Y)
110
+ networkId = keccak256(utf8(DOMAIN_NETWORK_ID) || X || Y) # users
111
+ memberNetworkId = keccak256(X || Y) # network members
111
112
  ```
112
113
 
113
- where `X || Y` is the account's uncompressed public key **coordinates without the `0x04` SEC1 prefix** (i.e. the 64-byte raw `X || Y`, not the 65-byte `0x04 || X || Y` form). `computeNetworkId` (`src/network-id.ts`) accepts either the 65-byte prefixed or 64-byte raw encoding as input and strips the prefix itself when present.
114
+ where `DOMAIN_NETWORK_ID = "coordination-network/identity/v2/network-id"` and `X || Y` is the account's uncompressed public key **coordinates without the `0x04` SEC1 prefix** (i.e. the 64-byte raw `X || Y`, not the 65-byte `0x04 || X || Y` form). `computeNetworkId` and `computeMemberNetworkId` (`src/network-id.ts`) accept either the 65-byte prefixed or 64-byte raw encoding as input and strip the prefix themselves when present.
114
115
 
115
- **Invariant:** the low 20 bytes (last 20 bytes) of `networkId` are exactly equal to the account's Ethereum address, because an Ethereum address is _defined_ as the low 20 bytes of `keccak256` of the same 64-byte uncompressed public key coordinates. A `networkId` is therefore not an arbitrary opaque identifier — it doubles as a verifiable, self-contained link to an on-chain address; anyone can check `networkId`'s last 20 bytes against a claimed address without any additional lookup.
116
+ **Invariant:** the low 20 bytes (last 20 bytes) of `memberNetworkId` are exactly equal to the account's Ethereum address, because an Ethereum address is _defined_ as the low 20 bytes of `keccak256` of the same 64-byte uncompressed public key coordinates. `networkId` has no such property: its input carries the domain tag, so it is not linkable to the account address by anyone who does not already hold the account public key.
116
117
 
117
- **Provenance of this construction:** hashing the bare 64-byte public key (not the compressed key, not the signature, not any other encoding) to get an identity commitment is the same convention used by [Semaphore](https://semaphore.pse.dev/)'s identity commitments and is exactly the serialization Ethereum itself uses to derive addresses — `v1` reuses it verbatim rather than defining a new one, precisely so the "last 20 bytes = address" invariant holds for free.
118
+ Members keep the plain hash because `NetworkMemberRegistry` derives a member's auth address from the low 20 bytes of its id, and a member's auth address is public anyway. Users get no benefit from that link, so their id does not carry it.
119
+
120
+ **Provenance of this construction:** hashing the bare 64-byte public key (not the compressed key, not the signature, not any other encoding) to get an identity commitment is the same convention used by [Semaphore](https://semaphore.pse.dev/)'s identity commitments and is exactly the serialization Ethereum itself uses to derive addresses — `memberNetworkId` reuses it verbatim rather than defining a new one, precisely so the "last 20 bytes = address" invariant holds for free. `networkId` prefixes the domain tag to the same bytes.
118
121
 
119
122
  ### 6.1 Erratum: the user integration spec's step-3 line
120
123
 
121
124
  The user integration spec's prose description of this scheme has a step-3 line that says, in effect, "`networkId = keccak256` over the RFC-6979 derivation-message signature." **This is an error in that document, not an alternate valid construction.** The diagram in the same document, its data-structures section, and the network-id section itself all independently specify `keccak256` over the **account public key**, and that is what this package implements and what is normative.
122
125
 
123
- Beyond the internal inconsistency, hashing the signature for `networkId` would additionally be actively hazardous: `sig64`'s two halves (`r`, `s`) are precisely the seeds for the spending and viewing private keys (§4.3). Any construction that publishes or otherwise makes `keccak256(sig64)` (or any function of `sig64` as a whole) discoverable narrows the search space around values that already double as private-key seed material, which is a poor property for a _public_, network-visible identifier to have. The pubkey-based construction has no such coupling: the account public key is already public by definition, and hashing it introduces no new relationship to the spending/viewing secrets.
126
+ Beyond the internal inconsistency, hashing the signature for `networkId` would additionally be actively hazardous: `sig64`'s two halves (`r`, `s`) are precisely the seeds for the spending and viewing private keys (§4.3). Any construction that publishes or otherwise makes `keccak256(sig64)` (or any function of `sig64` as a whole) discoverable narrows the search space around values that already double as private-key seed material, which is a poor property for a _public_, network-visible identifier to have. The pubkey-based construction has no such coupling: the account public key holds no secret, and hashing it introduces no new relationship to the spending/viewing secrets. Keeping that key off-chain is what keeps `networkId` unlinkable to the account address (§6).
124
127
 
125
128
  ## 7. Team decision: ECDSA only, no EdDSA
126
129
 
@@ -133,8 +136,8 @@ The 2026-07-13 record above is preserved verbatim, but it is no longer this docu
133
136
  The reinstated key is deliberately **not** a retraction of the 2026-07-13 decision's substance: the secp256k1 core (account, spending, viewing, auth, §3 to §5) is completely unchanged, byte-identical, and remains this scheme's root of trust. The identity key is instead exactly the kind of "new, explicitly versioned addition" the original 2026-07-13 note anticipated as the only acceptable path back to EdDSA:
134
137
 
135
138
  - It is a **standalone** derivation, `deriveEddsaKeypair` (`src/eddsa.ts`), additive to `deriveKeys`/`DerivedKeys`, and the secp256k1 key bundle and its shape are untouched.
136
- - Every existing `v1` constant, formula, and conformance vector (`derivation-vectors.json`, `binding-vectors.json`) stays byte-identical; the identity key's own conformance vectors live in a **separate** file, `eddsa-vectors.json` (§10).
137
- - It is a **stable, device-level identity key** — deterministically re-derivable, like the secp256k1 keys, but from the hardware-backed confirmation key rather than from `deviceSecret` itself (§12.1) — **not** the per-credential revocation secret `r` that `packages/credential` generates per credential (`generateRevocationSecret()`, `randomBytes(32)`). The two are unrelated: this key identifies the _device_ across the network; `r` is a one-off, random, per-credential value. Wiring the two together, if ever done, is an explicit future decision, not something this reinstatement implies.
139
+ - Every existing secp256k1-core constant, formula, and key vector in `derivation-vectors.json` stays byte-identical; the identity key's own conformance vectors live in a **separate** file, `eddsa-vectors.json` (§10).
140
+ - It is a **stable, device-level identity key** — deterministically re-derivable, like the secp256k1 keys, but from the hardware-backed device agreement key rather than from `deviceSecret` itself (§12.1) — **not** the per-credential revocation secret `r` that `packages/credential` generates per credential (`generateRevocationSecret()`, `randomBytes(32)`). The two are unrelated: this key identifies the _device_ across the network; `r` is a one-off, random, per-credential value. Wiring the two together, if ever done, is an explicit future decision, not something this reinstatement implies.
138
141
 
139
142
  See [§12](#12-baby-jubjub-identity-key-and-semaphore-commitment) for the normative formula.
140
143
 
@@ -143,7 +146,7 @@ See [§12](#12-baby-jubjub-identity-key-and-semaphore-commitment) for the normat
143
146
  Every hash in this document — `hashToScalar`, the EIP-191 digest, the spending/viewing keys, `networkId` — uses **keccak256**, not SHA-256, with one deliberate exception noted below. Reasons, in order of weight:
144
147
 
145
148
  1. **EVM-native.** keccak256 is the hash the EVM itself uses (the `KECCAK256` opcode, Ethereum address derivation, event topics). A scheme whose identifiers are meant to be checked against on-chain addresses and consumed by Solidity contracts should use the hash those contracts already use natively, rather than requiring a second hash primitive on-chain purely to reconcile conventions.
146
- 2. **Ecosystem consistency.** [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) (stealth addresses) and FluidKey's own tooling — the two external constructions `v1` is directly compatible with (§4.3, §6) — already use keccak256 throughout. Using anything else would mean translating between hash families at every integration boundary for no benefit.
149
+ 2. **Ecosystem consistency.** [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) (stealth addresses) and FluidKey's own tooling — the two external constructions `v2` is directly compatible with (§4.3, §6) — already use keccak256 throughout. Using anything else would mean translating between hash families at every integration boundary for no benefit.
147
150
  3. **Length-extension immunity.** Keccak (and so keccak256) is not vulnerable to the length-extension attacks that affect the plain Merkle–Damgård constructions in the SHA-2 family (SHA-256 included). This scheme does not rely on that property anywhere today, but it is a reason keccak256 is the safer default rather than merely the conventional one.
148
151
 
149
152
  **The exception: `confirmationKeyHash` (§11.2).** It is the RFC 7638 JWK thumbprint, which fixes SHA-256. None of the three reasons above apply to it: it is never checked on-chain, it has no ERC-5564 or FluidKey counterpart, and it is a fixed-length input so length extension is moot. The credential layer commits to the same P-256 key a second time, as a Poseidon value in the credential's claim tree, because a zero-knowledge circuit has to recompute it. That commitment is a separate value, specified in [VC issuance and verification](../../specs/03-vc-issuance.md); `confirmationKeyHash` itself is unchanged.
@@ -157,9 +160,12 @@ Every hash in this document — `hashToScalar`, the EIP-191 digest, the spending
157
160
  | `DOMAIN_AUTH` | `"coordination-network/identity/v1/auth"` | §5 — `hashToScalar` tag for the auth key. |
158
161
  | `DOMAIN_EDDSA` | `"coordination-network/identity/v1/eddsa"` | §12 — tag for the Baby Jubjub EdDSA seed (`eddsaSeed = keccak256(utf8(DOMAIN_EDDSA) \|\| ecdhSharedSecret)`), the identity key. Note this is a **plain keccak256 prefix**, not a `hashToScalar` reject-and-retry tag (§12 explains why no retry is needed here). |
159
162
  | `DOMAIN_EDDSA_PEER` | `"coordination-network/identity/v1/eddsa/peer"` | §12.1 — the `msg` input to RFC 9380 hash-to-curve when computing the fixed NUMS peer point `Q`. Consumed solely by RFC 9380 hash-to-curve: `DOMAIN_EDDSA_PEER` is the only tag in this table that never passes through `keccak256` in any form. |
163
+ | `DOMAIN_NETWORK_ID` | `"coordination-network/identity/v2/network-id"` | §6 — plain keccak256 prefix for a user's `networkId` (`networkId = keccak256(utf8(DOMAIN_NETWORK_ID) \|\| X \|\| Y)`). `memberNetworkId` uses no tag. |
160
164
  | `BINDING_NONCE_LENGTH` | `32` | §11 — required byte length of `nonce` and `networkId` in the credential-binding messages. A **length constraint**, not a domain-separation tag: it has no string value and is never itself hashed. §11.6 explains how a fixed length still provides domain separation, by construction rather than by content. |
161
165
 
162
- Every tag is prefixed with the scheme name and version (`coordination-network/identity/v1`), so that even if two tags were (hypothetically) applied to the same input bytes, their `hashToScalar` outputs would still differ. Note that the spending/viewing keys (§4.3) intentionally do **not** go through a tagged domain — see the FluidKey-compatibility discussion there for why.
166
+ Every tag is prefixed with the scheme name and the version that introduced its formula (`coordination-network/identity/v1`, `coordination-network/identity/v2`), so that even if two tags were (hypothetically) applied to the same input bytes, their hash outputs would still differ. Note that the spending/viewing keys (§4.3) intentionally do **not** go through a tagged domain — see the FluidKey-compatibility discussion there for why.
167
+
168
+ Tags are concatenated with no length prefix, so no tag hashed as `utf8(tag) || input` may be a prefix of another; `tests/keygen.test.ts` checks every keccak-prefixed `DOMAIN_*` constant. Two constants in the table do overlap others, harmlessly: `FIXED_DOMAIN_MSG` is hashed inside an EIP-191 frame, and `DOMAIN_EDDSA_PEER` goes only to RFC 9380 hash-to-curve.
163
169
 
164
170
  ### 9.1 Hash-to-scalar reject-and-retry, restated
165
171
 
@@ -167,28 +173,32 @@ For clarity, since this term recurs throughout this document: "hash-to-scalar re
167
173
 
168
174
  ### 9.2 Versioning policy
169
175
 
170
- Every constant in this document (`KEY_DERIVATION_VERSION`, `FIXED_DOMAIN_MSG`, `DOMAIN_ACCOUNT`, `DOMAIN_AUTH`) — and every formula that consumes them — is **frozen** for as long as `KEY_DERIVATION_VERSION = "v1"`. None of them may change silently: a silent change would make every device secret already in the field derive different keys than before, without any way to detect that it happened. Any change to any formula or constant in this document — including ones that look purely cosmetic — **must** ship as a new derivation scheme version (a new `KEY_DERIVATION_VERSION` string and, where the change affects them, new domain tags), never as an in-place edit to `v1`'s behavior.
176
+ Every constant in this document (`KEY_DERIVATION_VERSION`, `FIXED_DOMAIN_MSG`, `DOMAIN_ACCOUNT`, `DOMAIN_AUTH`, `DOMAIN_NETWORK_ID`) — and every formula that consumes them — is **frozen** for as long as `KEY_DERIVATION_VERSION = "v2"`. None of them may change silently: a silent change would make every device secret already in the field derive different keys than before, without any way to detect that it happened. Any change to any formula or constant in this document — including ones that look purely cosmetic — **must** ship as a new derivation scheme version (a new `KEY_DERIVATION_VERSION` string and, where the change affects them, new domain tags), never as an in-place edit to `v2`'s behavior.
177
+
178
+ A domain tag carries the revision that introduced its formula, not the current `KEY_DERIVATION_VERSION`. A revision that leaves a formula alone leaves its tag alone, which is why the key tags stay `/v1/` while `KEY_DERIVATION_VERSION` is `v2`: those keys are unchanged.
171
179
 
172
180
  ## 10. Conformance vectors
173
181
 
174
- `tests/vectors/derivation-vectors.json` is the conformance test vector file for this scheme: known `deviceSecret` inputs paired with every intermediate and final value defined in this document (`accountPrivKey`, `sig64`, `spendingPrivKey`, `viewingPrivKey`, `authPrivKey`, `networkId`, and the full `DerivedKeys` bundle). Any reimplementation of `v1` — in this package's own test suite or in a different language/runtime — should reproduce every value in that file exactly. It is the executable counterpart to this document: where prose and vectors could ever be read to disagree, the vectors (checked against this package's own source, which is itself the reference implementation) are what a compliant implementation is actually graded against.
182
+ `tests/vectors/derivation-vectors.json` is the conformance test vector file for this scheme: known `deviceSecret` inputs paired with every intermediate and final value defined in this document (`accountPrivKey`, `sig64`, `spendingPrivKey`, `viewingPrivKey`, `authPrivKey`, `networkId`, `memberNetworkId`, and the full `DerivedKeys` bundle). Any reimplementation of `v2` — in this package's own test suite or in a different language/runtime — should reproduce every value in that file exactly. It is the executable counterpart to this document: where prose and vectors could ever be read to disagree, the vectors (checked against this package's own source, which is itself the reference implementation) are what a compliant implementation is actually graded against.
175
183
 
176
- `tests/vectors/binding-vectors.json` is the analogous conformance vector file for the credential-binding scheme (§11): it reuses the first six `deviceSecret` values from `derivation-vectors.json`, paired with deterministic seeded nonces and P-256 test keys, and pins every intermediate and final value §11 defines (`confirmationKeyHash`, both message layouts, the `sig_A` digest, `sig_A`, and `sig_B`). See §11's own note on why `sig_B` in these vectors is a deterministic test signature rather than a randomized platform one.
184
+ `tests/vectors/binding-vectors.json` is the analogous conformance vector file for the credential-binding scheme (§11): it reuses the first six `deviceSecret` values from `derivation-vectors.json`, paired with deterministic seeded nonces and P-256 test keys, and pins every intermediate and final value §11 defines (`confirmationKeyHash`, both message layouts, the `sig_A` digest, `sig_A`, and `sig_B`). Each vector's `holderKeyCommitment` is the matching `eddsa-vectors.json` commitment; its `requestCommitment` is an opaque seeded value, since this package never computes the real one. See §11's own note on why `sig_B` in these vectors is a deterministic test signature rather than a randomized platform one.
177
185
 
178
186
  `tests/vectors/eddsa-vectors.json` is the conformance vector file for the Baby Jubjub identity key (§12): twelve seeded **test** P-256 keys stand in for the hardware key `hwPriv` (indices 0–5 deliberately coincide with the `hwPriv`/`confirmationKey` values in `binding-vectors.json` — a deliberate reuse, not a mistake, mirroring §11's own note on why `sig_B` in those vectors is a deterministic test signature rather than a randomized platform one), paired with `hardwarePrivateKey`, `hardwarePublicKey`, `ecdhSharedSecret`, `eddsaSeed`, `secretScalar`, `publicKey` (`[x, y]`), and `commitment` — the bigint fields encoded as decimal strings, since JSON has no native bigint type. `tests/eddsa-vectors.test.ts` replays both legs of the pipeline: it recomputes `ecdhSharedSecret` from `hardwarePrivateKey` in software (noble `p256.getSharedSecret` against the pinned peer point `Q`), pinning the ECDH leg, then feeds that value to `deriveEddsaKeypair` to reproduce every remaining field. `tests/semaphore-crosscheck.test.ts` additionally cross-checks every vector's `ecdhSharedSecret` (plus further seeded secrets) against `@semaphore-protocol/identity` directly, which is the stronger of the two guarantees — see §12's interop-contract note.
179
187
 
180
188
  ## 11. Credential-binding messages (dual proof-of-possession)
181
189
 
182
- Credential issuance binds two keys to one issuance session: the device's own secp256k1 **account key** (§3, "A") and a hardware-backed P-256 **confirmation key** ("B" — Secure Enclave, Android Keystore, or WebCrypto). Each key signs a message naming the same session `nonce` and the same `networkId` (§6), so an issuer that receives both signatures can be certain they were produced by the same device for the same session — a device signature cannot be lifted from one session and paired with an unrelated account signature (§11.5). This is implemented in `src/binding.ts` and `src/confirmation-key.ts`.
190
+ Credential issuance binds two keys to one issuance session: the device's own secp256k1 **account key** (§3, "A") and a hardware-backed P-256 **confirmation key** ("B" — Secure Enclave, Android Keystore, or WebCrypto). Each key signs a message naming the same session `nonce` and the same `networkId` (§6); the device message also commits to the holder key and to the issuance request, so an issuer that receives both signatures can be certain they were produced by the same device for the same session — a device signature cannot be lifted from one session and paired with an unrelated account signature (§11.5). This is implemented in `src/binding.ts` and `src/confirmation-key.ts`.
183
191
 
184
192
  ### 11.1 Message layouts
185
193
 
186
194
  ```
187
- deviceBindingMessage = nonce(32) || networkId(32) # 64 bytes, signed by B (P-256)
188
- accountBindingMessage = nonce(32) || confirmationKeyHash(32) || networkId(32) # 96 bytes, signed by A (secp256k1)
195
+ deviceBindingMessage = nonce(32) || networkId(32) || holderKeyCommitment(32) || requestCommitment(32) # 128 bytes, signed by B (P-256)
196
+ accountBindingMessage = nonce(32) || confirmationKeyHash(32) || networkId(32) # 96 bytes, signed by A (secp256k1)
189
197
  ```
190
198
 
191
- Strict spec-literal concatenation: neither message carries a domain-separation prefix. `nonce` and `networkId` MUST each be exactly `BINDING_NONCE_LENGTH` (32) bytes — see §11.6 for why this fixed length matters beyond mere tidiness.
199
+ Strict spec-literal concatenation: neither message carries a domain-separation prefix. `nonce`, `networkId`, `holderKeyCommitment` and `requestCommitment` MUST each be exactly 32 bytes (`BINDING_NONCE_LENGTH` for the first two) — see §11.6 for why the fixed lengths matter beyond mere tidiness.
200
+
201
+ `holderKeyCommitment` is the 32-byte big-endian encoding of the identity key's Semaphore commitment (§12). `requestCommitment` is opaque to this package: it commits the device signature to the issuer and the scope the user consented to, so the signature cannot be replayed to another issuer or for another scope. The credential layer defines its preimage, in [specs/03-vc-issuance.md](../../specs/03-vc-issuance.md), and implements it as `issuanceRequestCommitment` in `@coordination-network/credential`.
192
202
 
193
203
  ### 11.2 `confirmationKeyHash`
194
204
 
@@ -224,7 +234,7 @@ Two acceptance rules follow from what real platforms actually emit:
224
234
  - **Encoding.** `verifyDeviceBindingSignature` accepts a 64-byte compact `r || s` (what WebCrypto produces) or a DER-encoded signature (what Secure Enclave and Android Keystore produce), detected by length (64 bytes → compact) or, otherwise, a leading `0x30` DER `SEQUENCE` tag.
225
235
  - **`lowS` is deliberately not enforced.** Hardware P-256 signers are randomized (not RFC-6979 deterministic) and do not s-normalize their output. Rejecting a valid high-`s` hardware signature would be a false negative with no security benefit: `sig_B` is a one-shot liveness proof consumed once by the issuer, not a value whose malleability could be replayed into a different context — contrast secp256k1's `lowS: true` in §11.3, which matters because the account signature is verified against Ethereum's own canonical-signature convention.
226
236
 
227
- **Why confirmation key B is also the identity key's hardware root (§12.1).** Since PR#99, the same P-256 key is generated with both `PURPOSE_SIGN` and `PURPOSE_AGREE_KEY`, so it both produces `sig_B` here and performs the ECDH that roots the identity key (§12.1). This reuse was a deliberate acceptance, not an oversight: (1) the two uses are algorithmically disjoint — ECDSA signing and ECDH key agreement do not share state or a failure mode that could let one operation leak the other; (2) the ECDH peer is never attacker- or issuer-chosen — it is always the fixed, pinned NUMS point `Q` (§12.1), so nothing external ever gets to run key agreement against `B` with a point of its choosing; (3) one key keeps the credential-binding proof and the ZK-identity root co-resident in the same secure element, rather than requiring the platform to provision and protect two hardware keys for one device.
237
+ **Why confirmation key B is not the identity key's hardware root (§12.1).** Confirmation key B is the device signing key: a non-exportable P-256 key generated with `PURPOSE_SIGN` only, which needs the user (biometric, or device PIN, pattern or password) for every single use. The ECDH that roots the identity key (§12.1) runs on a second non-exportable P-256 key, the device agreement key, generated with `PURPOSE_AGREE_KEY` only, which needs user authentication within a 10-second window. The keys are separate because per-use user authentication on Android needs a BiometricPrompt CryptoObject per operation, and a KeyAgreement CryptoObject is available only from Android 16 QPR2, so one key cannot be both per-use and usable for ECDH on Android 12 and newer. The ECDH peer is never attacker- or issuer-chosen: it is always the fixed, pinned NUMS point `Q` (§12.1). Both keys require a secure lock screen, and removing it invalidates both. The platform layer then makes new keys, the identity key changes (§12.1), and credentials bound to the old B must be re-issued. The secp256k1 core and `networkId` are unaffected, since they derive from `deviceSecret`.
228
238
 
229
239
  ### 11.5 Mutual binding and the locally-derived-`networkId` MUST
230
240
 
@@ -236,11 +246,11 @@ Every function in `src/binding.ts` except the two low-level spec-literal builder
236
246
 
237
247
  ### 11.6 Injectivity of the two message types
238
248
 
239
- `deviceBindingMessage` is always exactly 64 bytes; `accountBindingMessage` is always exactly 96 bytes. Because `nonce` and `networkId` are each fixed at exactly `BINDING_NONCE_LENGTH` (32) bytes — never variable-length — the two message types can never collide: no 64-byte device message is ever also a valid 96-byte account message (or vice versa), and no byte sequence produced as one message type can be validly reinterpreted as the other. This is domain separation by fixed length rather than by tagged content — a different mechanism from §9's `hashToScalar` domain tags, but serving the same purpose: making sure a value computed for one role can never be mistaken for a value computed for a different one.
249
+ `deviceBindingMessage` is always exactly 128 bytes; `accountBindingMessage` is always exactly 96 bytes. Because every field of both is fixed at exactly 32 bytes — never variable-length — the two message types can never collide: no 128-byte device message is ever also a valid 96-byte account message (or vice versa), and no byte sequence produced as one message type can be validly reinterpreted as the other. This is domain separation by fixed length rather than by tagged content — a different mechanism from §9's `hashToScalar` domain tags, but serving the same purpose: making sure a value computed for one role can never be mistaken for a value computed for a different one.
240
250
 
241
- ### 11.7 Future hardening (v2, non-normative): P-256 cross-context domain separation
251
+ ### 11.7 Future hardening (non-normative): P-256 cross-context domain separation
242
252
 
243
- The `v1` binding messages, as specified, embed no P-256-specific domain-separation prefix — `sig_B`'s message is exactly `nonce || networkId`, with nothing marking it as belonging to this protocol rather than some other P-256-signing context that happened to reuse the same 64-byte layout. A future version could prepend a fixed context tag (analogous to `FIXED_DOMAIN_MSG`, §9) to `deviceBindingMessage` before hashing/signing, at the cost of no longer being able to verify a _raw_ platform signature over exactly `nonce || networkId` without that prefix. This is recorded here as a design note for a possible `v2`, not a requirement of `v1`: the current scheme's injectivity argument (§11.6) already rules out cross-message-type confusion within this protocol, and no cross-protocol P-256 signing context is known today that a `v1` `sig_B` could plausibly be confused with.
253
+ The `v2` binding messages, as specified, embed no P-256-specific domain-separation prefix — `sig_B`'s message is exactly the four concatenated fields of §11.1, with nothing marking it as belonging to this protocol rather than some other P-256-signing context that happened to reuse the same 128-byte layout. The only tag is the one inside `requestCommitment`'s hash, which scopes that field to issuance requests, not the message as a whole. A future version could prepend a fixed context tag (analogous to `FIXED_DOMAIN_MSG`, §9) to `deviceBindingMessage` before hashing/signing, at the cost of no longer being able to verify a _raw_ platform signature over exactly those fields without that prefix. This is recorded here as a design note for a future version, not a requirement of `v2`: the current scheme's injectivity argument (§11.6) already rules out cross-message-type confusion within this protocol, and no cross-protocol P-256 signing context is known today that a `sig_B` could plausibly be confused with.
244
254
 
245
255
  ## 12. Baby Jubjub identity key and Semaphore commitment
246
256
 
@@ -264,13 +274,19 @@ publicKey = derivePublicKey(eddsaSeed) # secretScala
264
274
  commitment = poseidon2([publicKey[0], publicKey[1]]) # Semaphore v4 identity commitment
265
275
  ```
266
276
 
267
- `Q` is the exported constant `EDDSA_PEER_POINT_HEX` (`src/eddsa-peer.ts`) — a 65-byte SEC1 uncompressed P-256 point, pinned above and reproduced by `computeEddsaPeerPoint()` (which recomputes `hashToCurve(utf8ToBytes(DOMAIN_EDDSA_PEER))` at runtime as reproducibility evidence that the pin is not hand-picked); `eddsaPeerPointBytes()` returns a fresh copy of the same 65 bytes on every call. `hwPriv` is the private key of the hardware-backed P-256 **confirmation key** — the same key ("B") that produces `sig_B` in §11 — and `X(...)` is the shared secret's x-coordinate, exactly what a platform ECDH primitive returns. This section replaces `v1`'s original formula (`eddsaSeed = keccak256(utf8(DOMAIN_EDDSA) || deviceSecret)`) in place, without a scheme version bump: `DOMAIN_EDDSA` is unchanged, but it now tags `ecdhSharedSecret` rather than `deviceSecret`. Five things follow from this construction:
277
+ Paymaster sponsorship uses a separate Baby Jubjub identity from the same `ecdhSharedSecret`:
278
+ `paymasterSeed = keccak256(utf8("coordination-network/identity/v1/paymaster") || ecdhSharedSecret)`.
279
+ `derivePaymasterKeypair` applies the same scalar, public-key and commitment steps to that seed.
280
+ Only its commitment may be added to the public paymaster group; the credential's
281
+ `holderKeyCommitment` remains private and must never be used as a group leaf.
282
+
283
+ `Q` is the exported constant `EDDSA_PEER_POINT_HEX` (`src/eddsa-peer.ts`) — a 65-byte SEC1 uncompressed P-256 point, pinned above and reproduced by `computeEddsaPeerPoint()` (which recomputes `hashToCurve(utf8ToBytes(DOMAIN_EDDSA_PEER))` at runtime as reproducibility evidence that the pin is not hand-picked); `eddsaPeerPointBytes()` returns a fresh copy of the same 65 bytes on every call. `hwPriv` is the private key of the hardware-backed P-256 **device agreement key** — a key generated for key agreement only, separate from the confirmation key ("B", the device signing key) that produces `sig_B` in §11 — and `X(...)` is the shared secret's x-coordinate, exactly what a platform ECDH primitive returns. This section replaces `v1`'s original formula (`eddsaSeed = keccak256(utf8(DOMAIN_EDDSA) || deviceSecret)`) in place, without a scheme version bump: `DOMAIN_EDDSA` is unchanged, but it now tags `ecdhSharedSecret` rather than `deviceSecret`. Five things follow from this construction:
268
284
 
269
- 1. **Hardware rooting.** `hwPriv` never leaves the secure element — it is generated non-exportable, with both `PURPOSE_SIGN` and `PURPOSE_AGREE_KEY` (Android Keystore terms; the analogous non-exportable, dual-purpose configuration applies on any platform this scheme is ported to). Computing `ecdhSharedSecret` therefore requires the specific piece of hardware that holds `hwPriv` — the identity key cannot be recomputed by software alone, unlike the secp256k1 core (§1), which is a pure function of `deviceSecret`.
285
+ 1. **Hardware rooting.** `hwPriv` never leaves the secure element — it is generated non-exportable, with `PURPOSE_AGREE_KEY` only (Android Keystore terms; the analogous non-exportable, agreement-only configuration applies on any platform this scheme is ported to). Computing `ecdhSharedSecret` therefore requires the specific piece of hardware that holds `hwPriv` — the identity key cannot be recomputed by software alone, unlike the secp256k1 core (§1), which is a pure function of `deviceSecret`.
270
286
  2. **Why `Q` must be a NUMS point.** ECDH is symmetric: anyone who knows the discrete log of `Q` could compute `hwPriv · Q` themselves, given `hwPriv`'s public counterpart, without ever touching the secure element — the hardware rooting in (1) would be worthless. `Q` is instead derived via RFC 9380 hash-to-curve from a fixed, published tag with no chosen input, so nobody — including whoever picked `DOMAIN_EDDSA_PEER` — knows or controls `dlog(Q)`. This is what makes `ecdhSharedSecret` computable only inside a device that actually holds `hwPriv`.
271
287
  3. **Why ECDH, not another hardware signature.** Hardware ECDSA-P256 signing (`sig_B`, §11.4) is randomized, not RFC-6979 deterministic — the same message signed twice yields two different signatures — so it cannot serve as a deterministic seed. ECDH key agreement against a fixed peer point is the one deterministic operation a non-exportable hardware key can perform: the same `(hwPriv, Q)` pair always yields the same `ecdhSharedSecret`.
272
288
  4. **Layering.** This package never talks to a keystore and never computes `hwPriv · Q` itself — `deriveEddsaKeypair` (`src/eddsa.ts`) takes `ecdhSharedSecret` as an opaque 32-byte input, computed by the platform layer (Android Keystore `KeyAgreement("ECDH")`, or equivalent) and handed in. `normalizeEcdhSharedSecret` (`src/bytes.ts`) validates it is exactly 32 bytes, delegating to the same check `normalizeDeviceSecret` performs but throwing `InvalidEcdhSecretError` (not `InvalidDeviceSecretError`, which would mischaracterize the input) on failure.
273
- 5. **MUST (normative), unrecoverability.** The identity key is **not** re-derivable from `deviceSecret` alone, and this is deliberate, not an oversight: losing access to hardware key `B` (device reset, Keystore wipe, lost/replaced secure element) permanently loses the ability to recompute this device's Semaphore commitment, even with `deviceSecret` still in hand. A new key `B` derives a new, unrelated identity key. This is an accepted trade-off of hardware-rooting the key at all — see §7's amendment for why this key exists, and note that the secp256k1 core and `networkId` are unaffected: those remain fully recoverable from `deviceSecret` alone.
289
+ 5. **MUST (normative), unrecoverability.** The identity key is **not** re-derivable from `deviceSecret` alone, and this is deliberate, not an oversight: losing access to the device agreement key (device reset, Keystore wipe, lost secure-element keys, removal of the secure lock screen, replaced secure element) permanently loses the ability to recompute this device's Semaphore commitment, even with `deviceSecret` still in hand. A new device agreement key derives a new, unrelated identity key. This is an accepted trade-off of hardware-rooting the key at all — see §7's amendment for why this key exists, and note that the secp256k1 core and `networkId` are unaffected: those remain fully recoverable from `deviceSecret` alone.
274
290
 
275
291
  `deriveEddsaKeypair` (`src/eddsa.ts`) is the sole implementation of this formula. It performs the `keccak256` step itself and then delegates `secretScalar`/`publicKey`/`commitment` entirely to `@zk-kit/eddsa-poseidon` (`deriveSecretScalar`, `derivePublicKey`) and `poseidon-lite` (`poseidon2`) — this package contains **no EdDSA or Baby Jubjub cryptography of its own**, and no ECDH cryptography of its own either.
276
292
 
@@ -310,4 +326,4 @@ The entire point of using `@zk-kit/eddsa-poseidon`/`poseidon-lite` rather than a
310
326
 
311
327
  ### 12.6 Dependencies, exact-pinned
312
328
 
313
- `@zk-kit/eddsa-poseidon@1.0.4` and `poseidon-lite@0.3.0` (both runtime dependencies of this package) are pinned to the **exact** versions `@semaphore-protocol/identity@4.14.3` (a devDependency here, used only for the cross-check) itself depends on. This is deliberate, not incidental: with matching exact versions, Yarn dedupes all three packages' dependency graphs to a single copy of `@zk-kit/eddsa-poseidon` and `poseidon-lite` in `node_modules`, so `deriveEddsaKeypair` and `new Identity(...)` run the literal same code, not merely semver-compatible code that happens to agree today. **Any future bump of either pin MUST be done in lockstep with whatever `@semaphore-protocol/identity` version is current, and MUST keep `tests/semaphore-crosscheck.test.ts` green** — that test is the guard against the two silently drifting apart.
329
+ `@zk-kit/eddsa-poseidon@1.0.4` and `poseidon-lite@0.3.0` (both runtime dependencies of this package) are pinned to the **exact** versions `@semaphore-protocol/identity@4.14.5` (a devDependency here, used only for the cross-check) itself depends on. This is deliberate, not incidental: with matching exact versions, Yarn dedupes all three packages' dependency graphs to a single copy of `@zk-kit/eddsa-poseidon` and `poseidon-lite` in `node_modules`, so `deriveEddsaKeypair` and `new Identity(...)` run the literal same code, not merely semver-compatible code that happens to agree today. **Any future bump of either pin MUST be done in lockstep with whatever `@semaphore-protocol/identity` version is current, and MUST keep `tests/semaphore-crosscheck.test.ts` green** — that test is the guard against the two silently drifting apart.