@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.
- package/DERIVATION.md +313 -0
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/index.cjs +957 -0
- package/dist/index.js +893 -0
- package/package.json +57 -0
- package/src/account.ts +61 -0
- package/src/auth.ts +20 -0
- package/src/binding.ts +298 -0
- package/src/bytes.ts +142 -0
- package/src/confirmation-key.ts +50 -0
- package/src/constants.ts +40 -0
- package/src/derive.ts +41 -0
- package/src/device-secret.ts +24 -0
- package/src/eddsa-peer.ts +32 -0
- package/src/eddsa.ts +37 -0
- package/src/errors.ts +76 -0
- package/src/hash-to-scalar.ts +35 -0
- package/src/index.ts +53 -0
- package/src/keypair.ts +38 -0
- package/src/network-id.ts +26 -0
- package/src/stealthAddresses/assertCompressedPublicKey.ts +25 -0
- package/src/stealthAddresses/buildAnnouncement.ts +37 -0
- package/src/stealthAddresses/checkStealthAddress.ts +37 -0
- package/src/stealthAddresses/computeSharedSecret.ts +16 -0
- package/src/stealthAddresses/decodeMetaStealthAddress.ts +27 -0
- package/src/stealthAddresses/deriveStealthKeys.ts +30 -0
- package/src/stealthAddresses/encodeMetaStealthAddress.ts +19 -0
- package/src/stealthAddresses/encodeMetaStealthAddressUri.ts +8 -0
- package/src/stealthAddresses/generateEphemeralKey.ts +12 -0
- package/src/stealthAddresses/generateStealthAddress.ts +27 -0
- package/src/stealthAddresses/generateStealthPrivateKey.ts +29 -0
- package/src/stealthAddresses/getStealthPublicKey.ts +21 -0
- package/src/stealthAddresses/getViewTag.ts +7 -0
- package/src/stealthAddresses/metaStealthAddressUri.ts +7 -0
- package/src/stealthAddresses/publicKeyToAddress.ts +17 -0
- package/src/stealthAddresses/validateMetaStealthAddress.ts +19 -0
- package/src/types.ts +51 -0
- 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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|