@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 +46 -30
- package/README.md +84 -72
- package/dist/index.cjs +85 -47
- package/dist/index.js +82 -48
- package/package.json +2 -2
- package/src/binding.ts +31 -13
- package/src/constants.ts +9 -3
- package/src/derive.ts +7 -4
- package/src/eddsa.ts +22 -13
- package/src/index.ts +2 -2
- package/src/network-id.ts +31 -14
- package/src/types.ts +5 -3
- package/types/index.d.ts +47 -23
package/DERIVATION.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Key derivation scheme `
|
|
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 **`
|
|
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 `
|
|
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 `
|
|
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; `
|
|
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
|
|
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`)
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
137
|
-
- It is a **stable, device-level identity key** — deterministically re-derivable, like the secp256k1 keys, but from the hardware-backed
|
|
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 `
|
|
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
|
|
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 = "
|
|
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 `
|
|
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)
|
|
188
|
-
accountBindingMessage = nonce(32) || confirmationKeyHash(32) || networkId(32)
|
|
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 `
|
|
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
|
|
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
|
|
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 (
|
|
251
|
+
### 11.7 Future hardening (non-normative): P-256 cross-context domain separation
|
|
242
252
|
|
|
243
|
-
The `
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|