@coordination-network/identity 0.0.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.
Files changed (39) hide show
  1. package/DERIVATION.md +313 -0
  2. package/LICENSE +21 -0
  3. package/README.md +258 -0
  4. package/dist/index.cjs +957 -0
  5. package/dist/index.js +893 -0
  6. package/package.json +57 -0
  7. package/src/account.ts +61 -0
  8. package/src/auth.ts +20 -0
  9. package/src/binding.ts +298 -0
  10. package/src/bytes.ts +142 -0
  11. package/src/confirmation-key.ts +50 -0
  12. package/src/constants.ts +40 -0
  13. package/src/derive.ts +41 -0
  14. package/src/device-secret.ts +24 -0
  15. package/src/eddsa-peer.ts +32 -0
  16. package/src/eddsa.ts +37 -0
  17. package/src/errors.ts +76 -0
  18. package/src/hash-to-scalar.ts +35 -0
  19. package/src/index.ts +53 -0
  20. package/src/keypair.ts +38 -0
  21. package/src/network-id.ts +26 -0
  22. package/src/stealthAddresses/assertCompressedPublicKey.ts +25 -0
  23. package/src/stealthAddresses/buildAnnouncement.ts +37 -0
  24. package/src/stealthAddresses/checkStealthAddress.ts +37 -0
  25. package/src/stealthAddresses/computeSharedSecret.ts +16 -0
  26. package/src/stealthAddresses/decodeMetaStealthAddress.ts +27 -0
  27. package/src/stealthAddresses/deriveStealthKeys.ts +30 -0
  28. package/src/stealthAddresses/encodeMetaStealthAddress.ts +19 -0
  29. package/src/stealthAddresses/encodeMetaStealthAddressUri.ts +8 -0
  30. package/src/stealthAddresses/generateEphemeralKey.ts +12 -0
  31. package/src/stealthAddresses/generateStealthAddress.ts +27 -0
  32. package/src/stealthAddresses/generateStealthPrivateKey.ts +29 -0
  33. package/src/stealthAddresses/getStealthPublicKey.ts +21 -0
  34. package/src/stealthAddresses/getViewTag.ts +7 -0
  35. package/src/stealthAddresses/metaStealthAddressUri.ts +7 -0
  36. package/src/stealthAddresses/publicKeyToAddress.ts +17 -0
  37. package/src/stealthAddresses/validateMetaStealthAddress.ts +19 -0
  38. package/src/types.ts +51 -0
  39. package/types/index.d.ts +499 -0
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@coordination-network/identity",
3
+ "version": "0.0.1",
4
+ "description": "Deterministic device key derivation for the coordination network: networkId and the four-key secp256k1 model (account, spending, viewing, auth) from one device secret, plus a hardware-rooted Baby Jubjub EdDSA/Semaphore-commitment fifth key.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zk-kit/coordination-network.git",
9
+ "directory": "packages/identity"
10
+ },
11
+ "homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/identity",
12
+ "bugs": "https://github.com/zk-kit/coordination-network/issues",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "main": "dist/index.cjs",
16
+ "module": "dist/index.js",
17
+ "types": "types/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./types/index.d.ts",
21
+ "require": "./dist/index.cjs",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist/",
27
+ "types/",
28
+ "src/",
29
+ "README.md",
30
+ "DERIVATION.md",
31
+ "LICENSE"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "rimraf dist types && rollup -c rollup.config.mjs"
38
+ },
39
+ "dependencies": {
40
+ "@noble/curves": "^1.9.7",
41
+ "@noble/hashes": "^1.8.0",
42
+ "@zk-kit/eddsa-poseidon": "1.0.4",
43
+ "poseidon-lite": "0.3.0"
44
+ },
45
+ "devDependencies": {
46
+ "@fluidkey/stealth-account-kit": "1.1.1",
47
+ "@rollup/plugin-typescript": "^12.3.0",
48
+ "@scopelift/stealth-address-sdk": "^1.0.0-beta.5",
49
+ "@semaphore-protocol/identity": "4.14.3",
50
+ "rimraf": "^6.1.3",
51
+ "rollup": "^4.62.2",
52
+ "rollup-plugin-cleanup": "^3.2.1",
53
+ "rollup-plugin-dts": "^6.4.1",
54
+ "tslib": "^2.8.0",
55
+ "typescript": "^6.0.3"
56
+ }
57
+ }
package/src/account.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { secp256k1 } from "@noble/curves/secp256k1"
2
+ import { keccak_256 } from "@noble/hashes/sha3"
3
+ import type { Hex, Secp256k1Keypair } from "./types"
4
+ import { DOMAIN_ACCOUNT, FIXED_DOMAIN_MSG } from "./constants"
5
+ import { bytesToHex, concatBytes, normalizeDeviceSecret, toBytes, utf8ToBytes } from "./bytes"
6
+ import { hashToScalar } from "./hash-to-scalar"
7
+ import { scalarToKeypair } from "./keypair"
8
+
9
+ /**
10
+ * Derives the root secp256k1 account keypair from a 32-byte device secret.
11
+ *
12
+ * NORMATIVE: `accountPrivKey = hashToScalar(DOMAIN_ACCOUNT, deviceSecret)`.
13
+ */
14
+ export function deriveAccountKeypair(deviceSecret: Uint8Array | Hex): Secp256k1Keypair {
15
+ const secret = normalizeDeviceSecret(deviceSecret)
16
+ const scalar = hashToScalar(DOMAIN_ACCOUNT, secret)
17
+
18
+ return scalarToKeypair(scalar)
19
+ }
20
+
21
+ /**
22
+ * Normalizes a 64-byte compact ECDSA signature (`r || s`) to raw bytes.
23
+ *
24
+ * @internal
25
+ */
26
+ export function normalizeSignature64(sig64: Uint8Array | Hex): Uint8Array {
27
+ const bytes = toBytes(sig64)
28
+
29
+ if (bytes.length !== 64) {
30
+ throw new TypeError("normalizeSignature64: expected a 64-byte compact signature (r || s)")
31
+ }
32
+
33
+ return bytes
34
+ }
35
+
36
+ /**
37
+ * EIP-191 personal-sign digest:
38
+ * `keccak256(utf8("\x19Ethereum Signed Message:\n" + decimalByteLength) || message)`.
39
+ *
40
+ * @internal
41
+ */
42
+ export function eip191Digest(message: Uint8Array): Uint8Array {
43
+ const prefix = utf8ToBytes(`\x19Ethereum Signed Message:\n${String(message.length)}`)
44
+
45
+ return keccak_256(concatBytes(prefix, message))
46
+ }
47
+
48
+ /**
49
+ * Signs the fixed derivation message with the account private key.
50
+ *
51
+ * NORMATIVE: the EIP-191 digest of {@link FIXED_DOMAIN_MSG}, signed with
52
+ * RFC-6979 deterministic ECDSA (`lowS: true`, no `extraEntropy`), returned as
53
+ * the 64-byte compact `r || s` encoding.
54
+ */
55
+ export function signDerivationMessage(accountPrivateKey: Hex | Uint8Array): Hex {
56
+ const privateKey = toBytes(accountPrivateKey)
57
+ const digest = eip191Digest(utf8ToBytes(FIXED_DOMAIN_MSG))
58
+ const signature = secp256k1.sign(digest, privateKey, { lowS: true })
59
+
60
+ return bytesToHex(signature.toCompactRawBytes())
61
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { Hex, Secp256k1Keypair } from "./types"
2
+ import { DOMAIN_AUTH } from "./constants"
3
+ import { normalizeSignature64 } from "./account"
4
+ import { hashToScalar } from "./hash-to-scalar"
5
+ import { scalarToKeypair } from "./keypair"
6
+
7
+ /**
8
+ * Derives the secp256k1 auth keypair from the 64-byte compact
9
+ * derivation-message signature.
10
+ *
11
+ * NORMATIVE: `authPrivKey = hashToScalar(DOMAIN_AUTH, sig64)`.
12
+ *
13
+ * `sig64` is unauthenticated — prefer {@link deriveKeys} (see `DERIVATION.md`).
14
+ */
15
+ export function deriveAuthKeypair(sig64: Uint8Array | Hex): Secp256k1Keypair {
16
+ const signature = normalizeSignature64(sig64)
17
+ const scalar = hashToScalar(DOMAIN_AUTH, signature)
18
+
19
+ return scalarToKeypair(scalar)
20
+ }
package/src/binding.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Credential dual proof-of-possession binding — see `DERIVATION.md` §11.
3
+ *
4
+ * NORMATIVE layouts:
5
+ * ```
6
+ * deviceBindingMessage = nonce(32) || networkId(32) || holderKeyCommitment(32) // credential issuance
7
+ * accountBindingMessage = nonce(32) || confirmationKeyHash(32) || networkId(32) // 96B, signed by A (secp256k1)
8
+ * ```
9
+ *
10
+ * NORMATIVE MUST: the device signs a locally-derived `networkId`, never one
11
+ * supplied by the issuance request. Every function here except the two
12
+ * low-level builders and {@link verifyDeviceBindingSignature} enforces this
13
+ * by deriving `networkId` internally rather than accepting it as a parameter.
14
+ */
15
+
16
+ import { secp256k1 } from "@noble/curves/secp256k1"
17
+ import { p256 } from "@noble/curves/p256"
18
+ import type { Hex } from "./types"
19
+ import { BINDING_NONCE_LENGTH } from "./constants"
20
+ import { bytesToHex, concatBytes, prependUncompressedPrefix, toBytes } from "./bytes"
21
+ import { eip191Digest, normalizeSignature64 } from "./account"
22
+ import { computeNetworkId } from "./network-id"
23
+ import { normalizeConfirmationKey, computeConfirmationKeyHash } from "./confirmation-key"
24
+ import { InvalidBindingInputError } from "./errors"
25
+
26
+ function toBindingBytes(value: Uint8Array | Hex, label: string): Uint8Array {
27
+ try {
28
+ return toBytes(value)
29
+ } catch {
30
+ throw new InvalidBindingInputError(`${label}: expected raw bytes or a well-formed 0x-prefixed hex string`)
31
+ }
32
+ }
33
+
34
+ function decodeAndValidateNonceAndNetworkId(
35
+ nonce: Uint8Array | Hex,
36
+ networkId: Uint8Array | Hex
37
+ ): { nonceBytes: Uint8Array; networkIdBytes: Uint8Array } {
38
+ const nonceBytes = toBindingBytes(nonce, "nonce")
39
+ const networkIdBytes = toBindingBytes(networkId, "networkId")
40
+
41
+ if (nonceBytes.length !== BINDING_NONCE_LENGTH) {
42
+ throw new InvalidBindingInputError(`nonce: expected exactly ${BINDING_NONCE_LENGTH} bytes`)
43
+ }
44
+
45
+ if (networkIdBytes.length !== BINDING_NONCE_LENGTH) {
46
+ throw new InvalidBindingInputError(`networkId: expected exactly ${BINDING_NONCE_LENGTH} bytes`)
47
+ }
48
+
49
+ return { nonceBytes, networkIdBytes }
50
+ }
51
+
52
+ /**
53
+ * Normalizes an `accountPublicKey` (65-byte uncompressed, 33-byte compressed,
54
+ * or 64-byte raw `X || Y`) to its 65-byte SEC1 uncompressed encoding,
55
+ * validating that it is on the secp256k1 curve.
56
+ */
57
+ function normalizeAccountPublicKey(accountPublicKey: Uint8Array | Hex): Uint8Array {
58
+ const bytes = toBindingBytes(accountPublicKey, "accountPublicKey")
59
+
60
+ if (bytes.length !== 33 && bytes.length !== 64 && bytes.length !== 65) {
61
+ throw new InvalidBindingInputError(
62
+ "accountPublicKey: expected a 65-byte uncompressed, 33-byte compressed, or 64-byte raw secp256k1 public key"
63
+ )
64
+ }
65
+
66
+ const sec1Bytes = bytes.length === 64 ? prependUncompressedPrefix(bytes) : bytes
67
+
68
+ try {
69
+ return secp256k1.ProjectivePoint.fromHex(sec1Bytes).toRawBytes(false)
70
+ } catch {
71
+ throw new InvalidBindingInputError("accountPublicKey: not a valid on-curve secp256k1 public key")
72
+ }
73
+ }
74
+
75
+ function normalizeAccountSignature(signature: Uint8Array | Hex): Uint8Array {
76
+ try {
77
+ return normalizeSignature64(signature)
78
+ } catch {
79
+ throw new InvalidBindingInputError("accountSignature: expected a 64-byte compact secp256k1 signature (r || s)")
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Parses a P-256 signature in either accepted platform encoding: 64-byte raw
85
+ * `r || s` (WebCrypto) or DER (Secure Enclave / Android Keystore). The DER
86
+ * `0x30` tag is checked before length because a genuine DER signature can
87
+ * itself be exactly 64 bytes.
88
+ */
89
+ function parseDeviceSignature(signature: Uint8Array | Hex): ReturnType<typeof p256.Signature.fromCompact> {
90
+ const bytes = toBindingBytes(signature, "deviceSignature")
91
+
92
+ if (bytes[0] === 0x30) {
93
+ try {
94
+ return p256.Signature.fromDER(bytes)
95
+ } catch {
96
+ // not valid DER; try compact
97
+ }
98
+ }
99
+
100
+ if (bytes.length === 64) {
101
+ try {
102
+ return p256.Signature.fromCompact(bytes)
103
+ } catch {
104
+ // not valid compact either; reject below
105
+ }
106
+ }
107
+
108
+ throw new InvalidBindingInputError(
109
+ "deviceSignature: expected a 64-byte compact (r || s) or DER-encoded P-256 signature"
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Builds `nonce(32) || networkId(32)`, optionally followed by a 32-byte holder commitment.
115
+ *
116
+ * Low-level, spec-literal builder — prefer {@link buildBindingMessages},
117
+ * which enforces the locally-derived-`networkId` MUST structurally.
118
+ */
119
+ export function buildDeviceBindingMessage(
120
+ nonce: Uint8Array | Hex,
121
+ networkId: Uint8Array | Hex,
122
+ holderKeyCommitment?: Uint8Array | Hex
123
+ ): Uint8Array {
124
+ const { nonceBytes, networkIdBytes } = decodeAndValidateNonceAndNetworkId(nonce, networkId)
125
+ if (holderKeyCommitment === undefined) return concatBytes(nonceBytes, networkIdBytes)
126
+ const holderBytes = toBindingBytes(holderKeyCommitment, "holderKeyCommitment")
127
+ if (holderBytes.length !== 32) throw new InvalidBindingInputError("holderKeyCommitment: expected exactly 32 bytes")
128
+ return concatBytes(nonceBytes, networkIdBytes, holderBytes)
129
+ }
130
+
131
+ /**
132
+ * Builds the 96-byte `accountBindingMessage`:
133
+ * `nonce(32) || confirmationKeyHash(32) || networkId(32)`.
134
+ *
135
+ * Low-level, spec-literal builder — see {@link buildBindingMessages} for the
136
+ * wallet-side entry point.
137
+ */
138
+ export function buildAccountBindingMessage(
139
+ nonce: Uint8Array | Hex,
140
+ confirmationKey: Uint8Array | Hex,
141
+ networkId: Uint8Array | Hex
142
+ ): Uint8Array {
143
+ const { nonceBytes, networkIdBytes } = decodeAndValidateNonceAndNetworkId(nonce, networkId)
144
+ const confirmationKeyHash = toBytes(computeConfirmationKeyHash(confirmationKey))
145
+
146
+ return concatBytes(nonceBytes, confirmationKeyHash, networkIdBytes)
147
+ }
148
+
149
+ /**
150
+ * Wallet-side entry point: builds both binding messages with `networkId`
151
+ * derived internally from the account's own public key, so a caller cannot
152
+ * supply a `networkId` from elsewhere even by mistake.
153
+ */
154
+ export function buildBindingMessages(
155
+ nonce: Uint8Array | Hex,
156
+ accountUncompressedPublicKey: Uint8Array | Hex,
157
+ confirmationKey: Uint8Array | Hex,
158
+ holderKeyCommitment?: Uint8Array | Hex
159
+ ): { networkId: Hex; deviceBindingMessage: Uint8Array; accountBindingMessage: Uint8Array } {
160
+ let networkId: Hex
161
+
162
+ try {
163
+ networkId = computeNetworkId(accountUncompressedPublicKey)
164
+ } catch {
165
+ throw new InvalidBindingInputError(
166
+ "accountUncompressedPublicKey: expected a 65-byte 0x04-prefixed or 64-byte raw uncompressed secp256k1 public key"
167
+ )
168
+ }
169
+
170
+ const deviceBindingMessage = buildDeviceBindingMessage(nonce, networkId, holderKeyCommitment)
171
+ const accountBindingMessage = buildAccountBindingMessage(nonce, confirmationKey, networkId)
172
+
173
+ return { networkId, deviceBindingMessage, accountBindingMessage }
174
+ }
175
+
176
+ /**
177
+ * Produces `sig_A`: the EIP-191 signature over the `accountBindingMessage`,
178
+ * with `networkId` derived internally from the account private key. Same
179
+ * signing posture as {@link signDerivationMessage} (RFC-6979 deterministic,
180
+ * `lowS: true`, no `extraEntropy`, 64-byte compact `r || s`).
181
+ */
182
+ export function signAccountBinding(
183
+ accountPrivateKey: Hex | Uint8Array,
184
+ nonce: Uint8Array | Hex,
185
+ confirmationKey: Uint8Array | Hex
186
+ ): { signature: Hex; networkId: Hex } {
187
+ const privateKeyBytes = toBytes(accountPrivateKey)
188
+ const uncompressedPublicKey = secp256k1.getPublicKey(privateKeyBytes, false)
189
+ const networkId = computeNetworkId(uncompressedPublicKey)
190
+
191
+ const message = buildAccountBindingMessage(nonce, confirmationKey, networkId)
192
+ const digest = eip191Digest(message)
193
+ const signature = secp256k1.sign(digest, privateKeyBytes, { lowS: true })
194
+
195
+ return { signature: bytesToHex(signature.toCompactRawBytes()), networkId }
196
+ }
197
+
198
+ /**
199
+ * Issuer-side verification of `sig_A`, with `networkId` derived internally
200
+ * from `accountPublicKey` — never accepted as a parameter.
201
+ *
202
+ * Malformed inputs throw {@link InvalidBindingInputError}; a well-formed
203
+ * signature that fails to verify returns `false`.
204
+ */
205
+ export function verifyAccountBindingSignature({
206
+ nonce,
207
+ confirmationKey,
208
+ accountPublicKey,
209
+ signature
210
+ }: {
211
+ nonce: Uint8Array | Hex
212
+ confirmationKey: Uint8Array | Hex
213
+ accountPublicKey: Uint8Array | Hex
214
+ signature: Uint8Array | Hex
215
+ }): boolean {
216
+ const publicKeyBytes = normalizeAccountPublicKey(accountPublicKey)
217
+ const networkId = computeNetworkId(publicKeyBytes)
218
+
219
+ const message = buildAccountBindingMessage(nonce, confirmationKey, networkId)
220
+ const digest = eip191Digest(message)
221
+ const signatureBytes = normalizeAccountSignature(signature)
222
+
223
+ return secp256k1.verify(signatureBytes, digest, publicKeyBytes, { lowS: true })
224
+ }
225
+
226
+ /**
227
+ * Issuer-side verification of `sig_B` (hardware P-256) over the
228
+ * `deviceBindingMessage`. NORMATIVE MUST: `networkId` — not derivable here,
229
+ * since `sig_B` is independent of the account key — must be the device's
230
+ * locally-derived value; prefer {@link verifyCredentialBinding}.
231
+ *
232
+ * `prehash: true` (signers hash SHA-256 internally); `lowS: false` (hardware
233
+ * signatures aren't s-normalized — `DERIVATION.md` §11.4).
234
+ */
235
+ export function verifyDeviceBindingSignature({
236
+ nonce,
237
+ networkId,
238
+ confirmationKey,
239
+ holderKeyCommitment,
240
+ signature
241
+ }: {
242
+ nonce: Uint8Array | Hex
243
+ networkId: Uint8Array | Hex
244
+ confirmationKey: Uint8Array | Hex
245
+ holderKeyCommitment?: Uint8Array | Hex
246
+ signature: Uint8Array | Hex
247
+ }): boolean {
248
+ const message = buildDeviceBindingMessage(nonce, networkId, holderKeyCommitment)
249
+ const publicKeyBytes = prependUncompressedPrefix(normalizeConfirmationKey(confirmationKey))
250
+ const parsedSignature = parseDeviceSignature(signature)
251
+
252
+ return p256.verify(parsedSignature.toCompactRawBytes(), message, publicKeyBytes, {
253
+ prehash: true,
254
+ lowS: false
255
+ })
256
+ }
257
+
258
+ /**
259
+ * The full issuer gate: one `networkId` derived from `accountPublicKey` gates
260
+ * both `sig_A` and `sig_B`, so a device signature from one session can't pair
261
+ * with an unrelated account signature in either direction. `networkId` is
262
+ * returned even when `valid` is false, for issuer-side logging/rate-limiting.
263
+ */
264
+ export function verifyCredentialBinding({
265
+ nonce,
266
+ accountPublicKey,
267
+ confirmationKey,
268
+ holderKeyCommitment,
269
+ accountSignature,
270
+ deviceSignature
271
+ }: {
272
+ nonce: Uint8Array | Hex
273
+ accountPublicKey: Uint8Array | Hex
274
+ confirmationKey: Uint8Array | Hex
275
+ holderKeyCommitment?: Uint8Array | Hex
276
+ accountSignature: Uint8Array | Hex
277
+ deviceSignature: Uint8Array | Hex
278
+ }): { valid: boolean; networkId: Hex } {
279
+ const publicKeyBytes = normalizeAccountPublicKey(accountPublicKey)
280
+ const networkId = computeNetworkId(publicKeyBytes)
281
+
282
+ const accountValid = verifyAccountBindingSignature({
283
+ nonce,
284
+ confirmationKey,
285
+ accountPublicKey: publicKeyBytes,
286
+ signature: accountSignature
287
+ })
288
+
289
+ const deviceValid = verifyDeviceBindingSignature({
290
+ nonce,
291
+ networkId,
292
+ confirmationKey,
293
+ holderKeyCommitment,
294
+ signature: deviceSignature
295
+ })
296
+
297
+ return { valid: accountValid && deviceValid, networkId }
298
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { bytesToHex as nobleBytesToHex, hexToBytes as nobleHexToBytes } from "@noble/hashes/utils"
2
+
3
+ import type { Hex } from "./types"
4
+ import { InvalidDeviceSecretError, InvalidEcdhSecretError } from "./errors"
5
+
6
+ const HEX_RE = /^[0-9a-fA-F]*$/
7
+
8
+ export { utf8ToBytes, concatBytes } from "@noble/hashes/utils"
9
+
10
+ export function hexToBytes(hex: string): Uint8Array {
11
+ if (typeof hex !== "string" || !hex.startsWith("0x")) {
12
+ throw new TypeError("hexToBytes: expected a string with a 0x prefix")
13
+ }
14
+
15
+ const body = hex.slice(2)
16
+
17
+ if (body.length % 2 !== 0) {
18
+ throw new TypeError("hexToBytes: expected an even number of hex characters")
19
+ }
20
+
21
+ if (!HEX_RE.test(body)) {
22
+ throw new TypeError("hexToBytes: expected only hex characters after the 0x prefix")
23
+ }
24
+
25
+ return nobleHexToBytes(body)
26
+ }
27
+
28
+ export function bytesToHex(bytes: Uint8Array): Hex {
29
+ return `0x${nobleBytesToHex(bytes)}`
30
+ }
31
+
32
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
33
+
34
+ /** Unpadded base64url (RFC 4648 §5) */
35
+ export function bytesToBase64url(bytes: Uint8Array): string {
36
+ let out = ""
37
+ let i = 0
38
+
39
+ for (; i + 2 < bytes.length; i += 3) {
40
+ const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2]
41
+
42
+ out +=
43
+ BASE64URL_ALPHABET[(chunk >> 18) & 63] +
44
+ BASE64URL_ALPHABET[(chunk >> 12) & 63] +
45
+ BASE64URL_ALPHABET[(chunk >> 6) & 63] +
46
+ BASE64URL_ALPHABET[chunk & 63]
47
+ }
48
+
49
+ const remaining = bytes.length - i
50
+
51
+ if (remaining === 1) {
52
+ const chunk = bytes[i] << 16
53
+
54
+ out += BASE64URL_ALPHABET[(chunk >> 18) & 63] + BASE64URL_ALPHABET[(chunk >> 12) & 63]
55
+ } else if (remaining === 2) {
56
+ const chunk = (bytes[i] << 16) | (bytes[i + 1] << 8)
57
+
58
+ out +=
59
+ BASE64URL_ALPHABET[(chunk >> 18) & 63] +
60
+ BASE64URL_ALPHABET[(chunk >> 12) & 63] +
61
+ BASE64URL_ALPHABET[(chunk >> 6) & 63]
62
+ }
63
+
64
+ return out
65
+ }
66
+
67
+ /** Decodes unpadded base64url (RFC 4648 §5). */
68
+ export function base64urlToBytes(encoded: string): Uint8Array {
69
+ if (!/^[A-Za-z0-9_-]*$/.test(encoded) || encoded.length % 4 === 1) {
70
+ throw new TypeError("base64urlToBytes: expected unpadded base64url")
71
+ }
72
+ const output = new Uint8Array(Math.floor((encoded.length * 6) / 8))
73
+ let buffer = 0
74
+ let bits = 0
75
+ let offset = 0
76
+ for (const character of encoded) {
77
+ buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(character)
78
+ bits += 6
79
+ if (bits >= 8) {
80
+ bits -= 8
81
+ output[offset++] = (buffer >> bits) & 0xff
82
+ buffer &= (1 << bits) - 1
83
+ }
84
+ }
85
+ if (buffer !== 0) throw new TypeError("base64urlToBytes: non-canonical trailing bits")
86
+ return output
87
+ }
88
+
89
+ export function toBytes(value: Uint8Array | Hex): Uint8Array {
90
+ return value instanceof Uint8Array ? value : hexToBytes(value)
91
+ }
92
+
93
+ /** Prepends the SEC1 `0x04` prefix to a 64-byte raw `X || Y` pair (secp256k1 or P-256). */
94
+ export function prependUncompressedPrefix(coordinates: Uint8Array): Uint8Array {
95
+ const out = new Uint8Array(65)
96
+ out[0] = 0x04
97
+ out.set(coordinates, 1)
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Normalizes a device secret to a defensive copy of exactly 32 bytes.
103
+ * Accepts a 32-byte `Uint8Array` or a `0x`-prefixed 64-hex-char string;
104
+ * anything else throws {@link InvalidDeviceSecretError}.
105
+ */
106
+ export function normalizeDeviceSecret(secret: Uint8Array | Hex | string): Uint8Array {
107
+ if (secret instanceof Uint8Array) {
108
+ if (secret.length !== 32) {
109
+ throw new InvalidDeviceSecretError()
110
+ }
111
+
112
+ return secret.slice()
113
+ }
114
+
115
+ if (typeof secret === "string") {
116
+ if (!secret.startsWith("0x") || secret.length !== 66 || !HEX_RE.test(secret.slice(2))) {
117
+ throw new InvalidDeviceSecretError()
118
+ }
119
+
120
+ return hexToBytes(secret)
121
+ }
122
+
123
+ throw new InvalidDeviceSecretError()
124
+ }
125
+
126
+ /**
127
+ * Normalizes a hardware ECDH shared secret to a defensive copy of exactly 32
128
+ * bytes. Same exactly-32-bytes contract as {@link normalizeDeviceSecret};
129
+ * rethrown as {@link InvalidEcdhSecretError} so callers don't conflate the
130
+ * two secrets by error code.
131
+ */
132
+ export function normalizeEcdhSharedSecret(secret: Uint8Array | Hex | string): Uint8Array {
133
+ try {
134
+ return normalizeDeviceSecret(secret)
135
+ } catch (error) {
136
+ if (error instanceof InvalidDeviceSecretError) {
137
+ throw new InvalidEcdhSecretError()
138
+ }
139
+
140
+ throw error
141
+ }
142
+ }
@@ -0,0 +1,50 @@
1
+ import { p256 } from "@noble/curves/p256"
2
+ import type { Hex } from "./types"
3
+ import { bytesToBase64url, bytesToHex, prependUncompressedPrefix, toBytes } from "./bytes"
4
+ import { sha256 } from "@noble/hashes/sha2"
5
+ import { InvalidBindingInputError } from "./errors"
6
+
7
+ /**
8
+ * Normalizes a P-256 `confirmationKey` to its raw 64-byte `X || Y`
9
+ * coordinates, validating that the point is on the curve. Accepts a 65-byte
10
+ * SEC1 uncompressed, 33-byte compressed, or 64-byte raw `X || Y` encoding.
11
+ */
12
+ export function normalizeConfirmationKey(confirmationKey: Uint8Array | Hex): Uint8Array {
13
+ let bytes: Uint8Array
14
+
15
+ try {
16
+ bytes = toBytes(confirmationKey)
17
+ } catch {
18
+ throw new InvalidBindingInputError(
19
+ "normalizeConfirmationKey: expected raw bytes or a well-formed 0x-prefixed hex string"
20
+ )
21
+ }
22
+
23
+ if (bytes.length !== 33 && bytes.length !== 64 && bytes.length !== 65) {
24
+ throw new InvalidBindingInputError(
25
+ "normalizeConfirmationKey: expected a 65-byte uncompressed, 33-byte compressed, or 64-byte raw P-256 public key"
26
+ )
27
+ }
28
+
29
+ const sec1Bytes = bytes.length === 64 ? prependUncompressedPrefix(bytes) : bytes
30
+
31
+ try {
32
+ return p256.ProjectivePoint.fromHex(sec1Bytes).toRawBytes(false).slice(1)
33
+ } catch {
34
+ throw new InvalidBindingInputError("normalizeConfirmationKey: not a valid on-curve P-256 public key")
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Computes the `confirmationKeyHash` embedded in the account binding message.
40
+ *
41
+ * NORMATIVE: the RFC 7638 JWK thumbprint of the P-256 confirmation key, i.e.
42
+ * `sha256(utf8('{"crv":"P-256","kty":"EC","x":<base64url X>,"y":<base64url Y>}'))`
43
+ */
44
+ export function computeConfirmationKeyHash(confirmationKey: Uint8Array | Hex): Hex {
45
+ const coordinates = normalizeConfirmationKey(confirmationKey)
46
+ const x = bytesToBase64url(coordinates.slice(0, 32))
47
+ const y = bytesToBase64url(coordinates.slice(32))
48
+
49
+ return bytesToHex(sha256(`{"crv":"P-256","kty":"EC","x":"${x}","y":"${y}"}`))
50
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Normative constants for the `v1` key derivation scheme. Changing any of
3
+ * these changes every derived key and `networkId`, so they MUST NOT change
4
+ * without a version bump (new `KEY_DERIVATION_VERSION` and domain strings).
5
+ */
6
+
7
+ export const KEY_DERIVATION_VERSION = "v1" as const
8
+
9
+ /** Fixed domain-separation message signed by {@link signDerivationMessage}. */
10
+ export const FIXED_DOMAIN_MSG = "coordination-network/identity/v1" as const
11
+
12
+ /** Domain-separation tag for deriving the account keypair from a device secret. */
13
+ export const DOMAIN_ACCOUNT = "coordination-network/identity/v1/account" as const
14
+
15
+ /** Domain-separation tag for deriving the auth keypair. */
16
+ export const DOMAIN_AUTH = "coordination-network/identity/v1/auth" as const
17
+
18
+ /** Domain-separation tag for deriving the Baby Jubjub EdDSA seed (the "fifth key") from the hardware ECDH shared secret. */
19
+ export const DOMAIN_EDDSA = "coordination-network/identity/v1/eddsa" as const
20
+
21
+ /** Domain-separation message hashed to the fixed NUMS P-256 peer point `Q` for the fifth-key ECDH (`eddsa-peer.ts`). */
22
+ export const DOMAIN_EDDSA_PEER = "coordination-network/identity/v1/eddsa/peer" as const
23
+
24
+ /**
25
+ * Byte length of `nonce` and `networkId` in the binding messages — fixed at
26
+ * 32 so the 64- vs 96-byte messages are injective.
27
+ */
28
+ export const BINDING_NONCE_LENGTH = 32 as const
29
+
30
+ /** SEC1-compressed secp256k1 public key length. */
31
+ export const COMPRESSED_PUBLIC_KEY_LENGTH = 33 as const
32
+
33
+ /** ERC-6538 scheme 1 meta-stealth address: compressed spending ‖ viewing public keys. */
34
+ export const META_STEALTH_ADDRESS_LENGTH = 66 as const
35
+
36
+ /** ERC-5564 offchain display prefix — never stored onchain. */
37
+ export const META_STEALTH_URI_PREFIX = "st:eth:" as const
38
+
39
+ /** ERC-5564 scheme id for secp256k1 with view tags. */
40
+ export const ERC_5564_SCHEME_ID = 1 as const