@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/src/derive.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DerivedKeys, Hex } from "./types"
|
|
2
|
+
import { KEY_DERIVATION_VERSION } from "./constants"
|
|
3
|
+
import { deriveAccountKeypair, signDerivationMessage } from "./account"
|
|
4
|
+
import { deriveStealthKeys } from "./stealthAddresses/deriveStealthKeys"
|
|
5
|
+
import { deriveAuthKeypair } from "./auth"
|
|
6
|
+
import { computeNetworkId } from "./network-id"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Derives the full `v1` device identity — `networkId` plus the four-key
|
|
10
|
+
* all-secp256k1 model — from a single device secret.
|
|
11
|
+
*
|
|
12
|
+
* Pipeline (NORMATIVE):
|
|
13
|
+
* 1. `account = deriveAccountKeypair(deviceSecret)`
|
|
14
|
+
* 2. `networkId = computeNetworkId(account.uncompressedPublicKey)`
|
|
15
|
+
* 3. `sig = signDerivationMessage(account.privateKey)` (EIP-191, deterministic)
|
|
16
|
+
* 4. `{ spending, viewing } = deriveStealthKeys(sig)` (FluidKey semantics)
|
|
17
|
+
* 5. `auth = deriveAuthKeypair(sig)`
|
|
18
|
+
*/
|
|
19
|
+
export function deriveKeys(deviceSecret: Uint8Array | Hex): DerivedKeys {
|
|
20
|
+
const account = deriveAccountKeypair(deviceSecret)
|
|
21
|
+
const networkId = computeNetworkId(account.uncompressedPublicKey)
|
|
22
|
+
const signature = signDerivationMessage(account.privateKey)
|
|
23
|
+
const { spending, viewing } = deriveStealthKeys(signature)
|
|
24
|
+
const auth = deriveAuthKeypair(signature)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
version: KEY_DERIVATION_VERSION,
|
|
28
|
+
networkId,
|
|
29
|
+
account,
|
|
30
|
+
spending,
|
|
31
|
+
viewing,
|
|
32
|
+
auth
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Derives only the `networkId` — equal to `deriveKeys(deviceSecret).networkId`, minus the signature work. */
|
|
37
|
+
export function deriveNetworkId(deviceSecret: Uint8Array | Hex): Hex {
|
|
38
|
+
const account = deriveAccountKeypair(deviceSecret)
|
|
39
|
+
|
|
40
|
+
return computeNetworkId(account.uncompressedPublicKey)
|
|
41
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Hex } from "./types"
|
|
2
|
+
import { bytesToHex } from "./bytes"
|
|
3
|
+
import { KeygenError } from "./errors"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a fresh 32-byte device secret from the platform CSPRNG
|
|
7
|
+
* (`globalThis.crypto.getRandomValues`). Throws if no secure RNG is available
|
|
8
|
+
* (e.g. React Native without the `react-native-get-random-values` polyfill).
|
|
9
|
+
*/
|
|
10
|
+
export function generateDeviceSecret(): Hex {
|
|
11
|
+
const cryptoObj = globalThis.crypto
|
|
12
|
+
|
|
13
|
+
if (typeof cryptoObj?.getRandomValues !== "function") {
|
|
14
|
+
throw new KeygenError(
|
|
15
|
+
"DERIVATION_FAILURE",
|
|
16
|
+
"generateDeviceSecret: no secure random source (globalThis.crypto.getRandomValues) available"
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const bytes = new Uint8Array(32)
|
|
21
|
+
cryptoObj.getRandomValues(bytes)
|
|
22
|
+
|
|
23
|
+
return bytesToHex(bytes)
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Fixed NUMS P-256 peer point `Q` for the fifth-key ECDH — see `DERIVATION.md` §12. */
|
|
2
|
+
|
|
3
|
+
import { hashToCurve, p256 } from "@noble/curves/p256"
|
|
4
|
+
import { hexToBytes, utf8ToBytes } from "./bytes"
|
|
5
|
+
import { DOMAIN_EDDSA_PEER } from "./constants"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* NORMATIVE: `Q = hashToCurve(utf8(DOMAIN_EDDSA_PEER))` (RFC 9380
|
|
9
|
+
* `P256_XMD:SHA-256_SSWU_RO_`, suite-default DST). Nobody knows `dlog(Q)`,
|
|
10
|
+
* so `hwPriv · Q` is computable only by the device that holds `hwPriv`.
|
|
11
|
+
* 65-byte SEC1 uncompressed encoding, `0x04`-prefixed.
|
|
12
|
+
*/
|
|
13
|
+
export const EDDSA_PEER_POINT_HEX =
|
|
14
|
+
"0x045db1cabf31539848bf802ad49a94ad8f8e3d52e75bb53aa4552d7cf836eba054213badca0cf4ba6daf8c146d3415d9ae1905440781dc5f08e6e946a471af99e9" as const
|
|
15
|
+
|
|
16
|
+
/** Fresh copy of {@link EDDSA_PEER_POINT_HEX} as raw bytes. */
|
|
17
|
+
export function eddsaPeerPointBytes(): Uint8Array {
|
|
18
|
+
return hexToBytes(EDDSA_PEER_POINT_HEX)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reproduces `Q` from its RFC 9380 provenance, independent of the pinned
|
|
23
|
+
* constant — the evidence that {@link EDDSA_PEER_POINT_HEX} is a NUMS point.
|
|
24
|
+
*
|
|
25
|
+
* `hashToCurve` returns an `H2CPoint`, which lacks `toRawBytes`; round-trip
|
|
26
|
+
* through `ProjectivePoint.fromAffine` to get the SEC1 encoding.
|
|
27
|
+
*/
|
|
28
|
+
export function computeEddsaPeerPoint(): Uint8Array {
|
|
29
|
+
const point = hashToCurve(utf8ToBytes(DOMAIN_EDDSA_PEER))
|
|
30
|
+
|
|
31
|
+
return p256.ProjectivePoint.fromAffine(point.toAffine()).toRawBytes(false)
|
|
32
|
+
}
|
package/src/eddsa.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Baby Jubjub EdDSA "fifth key" derivation — see `DERIVATION.md` §12. */
|
|
2
|
+
|
|
3
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
4
|
+
import { deriveSecretScalar, derivePublicKey } from "@zk-kit/eddsa-poseidon"
|
|
5
|
+
import { poseidon2 } from "poseidon-lite/poseidon2"
|
|
6
|
+
import type { EddsaKeypair, Hex } from "./types"
|
|
7
|
+
import { DOMAIN_EDDSA } from "./constants"
|
|
8
|
+
import { bytesToHex, concatBytes, normalizeEcdhSharedSecret, utf8ToBytes } from "./bytes"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derives the Baby Jubjub EdDSA keypair and Semaphore-v4 identity commitment
|
|
12
|
+
* (the "fifth key"), rooted in a hardware ECDH shared secret.
|
|
13
|
+
*
|
|
14
|
+
* NORMATIVE (`DERIVATION.md` §12): `ecdhSharedSecret = X(hwPriv · Q)` for the
|
|
15
|
+
* fixed NUMS peer point `Q` (`eddsa-peer.ts`), computed natively by the
|
|
16
|
+
* caller — the library never talks to the Keystore, it only takes the raw
|
|
17
|
+
* 32-byte result. `seed = keccak256(utf8(DOMAIN_EDDSA) || ecdhSharedSecret)`,
|
|
18
|
+
* fed to `@zk-kit/eddsa-poseidon` as raw bytes (MUST NOT be the hex string,
|
|
19
|
+
* see §12.4); `commitment = poseidon2([x, y])`.
|
|
20
|
+
*
|
|
21
|
+
* @throws {InvalidEcdhSecretError} if `ecdhSharedSecret` is not exactly 32 bytes
|
|
22
|
+
*/
|
|
23
|
+
export function deriveEddsaKeypair(ecdhSharedSecret: Uint8Array | Hex): EddsaKeypair {
|
|
24
|
+
const secret = normalizeEcdhSharedSecret(ecdhSharedSecret)
|
|
25
|
+
const seed = keccak_256(concatBytes(utf8ToBytes(DOMAIN_EDDSA), secret))
|
|
26
|
+
|
|
27
|
+
const secretScalar = deriveSecretScalar(seed)
|
|
28
|
+
const publicKey = derivePublicKey(seed)
|
|
29
|
+
const commitment = poseidon2(publicKey)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
seed: bytesToHex(seed),
|
|
33
|
+
secretScalar,
|
|
34
|
+
publicKey,
|
|
35
|
+
commitment
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type KeygenErrorCode =
|
|
2
|
+
| "INVALID_DEVICE_SECRET"
|
|
3
|
+
| "DERIVATION_FAILURE"
|
|
4
|
+
| "INVALID_BINDING_INPUT"
|
|
5
|
+
| "INVALID_ECDH_SECRET"
|
|
6
|
+
| "INVALID_META_STEALTH_ADDRESS"
|
|
7
|
+
| "INVALID_PUBLIC_KEY_LENGTH"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base error class for all errors thrown by this package, discriminated by
|
|
11
|
+
* `code` so callers can branch without `instanceof` across bundle boundaries.
|
|
12
|
+
*/
|
|
13
|
+
export class KeygenError extends Error {
|
|
14
|
+
readonly code: KeygenErrorCode
|
|
15
|
+
|
|
16
|
+
constructor(code: KeygenErrorCode, message: string) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = "KeygenError"
|
|
19
|
+
this.code = code
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Thrown when a device secret does not satisfy the exactly-32-bytes contract. */
|
|
24
|
+
export class InvalidDeviceSecretError extends KeygenError {
|
|
25
|
+
constructor(
|
|
26
|
+
message: string = "Invalid device secret: expected a 32-byte Uint8Array or a 0x-prefixed 64-character hex string"
|
|
27
|
+
) {
|
|
28
|
+
super("INVALID_DEVICE_SECRET", message)
|
|
29
|
+
this.name = "InvalidDeviceSecretError"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Thrown when key derivation fails for a reason unrelated to input shape. */
|
|
34
|
+
export class DerivationFailureError extends KeygenError {
|
|
35
|
+
constructor(message: string = "Key derivation failed") {
|
|
36
|
+
super("DERIVATION_FAILURE", message)
|
|
37
|
+
this.name = "DerivationFailureError"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Thrown for malformed credential-binding inputs; a well-formed signature
|
|
43
|
+
* that merely fails to verify returns `false` instead.
|
|
44
|
+
*/
|
|
45
|
+
export class InvalidBindingInputError extends KeygenError {
|
|
46
|
+
constructor(message: string = "Invalid credential-binding input") {
|
|
47
|
+
super("INVALID_BINDING_INPUT", message)
|
|
48
|
+
this.name = "InvalidBindingInputError"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Thrown when a meta-stealth address fails to decode (bad prefix, length, hex, or curve point). */
|
|
53
|
+
export class InvalidMetaStealthAddressError extends KeygenError {
|
|
54
|
+
constructor(message: string = "Invalid meta-stealth address") {
|
|
55
|
+
super("INVALID_META_STEALTH_ADDRESS", message)
|
|
56
|
+
this.name = "InvalidMetaStealthAddressError"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Thrown when a hardware ECDH shared secret does not satisfy the exactly-32-bytes contract. */
|
|
61
|
+
export class InvalidEcdhSecretError extends KeygenError {
|
|
62
|
+
constructor(
|
|
63
|
+
message: string = "Invalid ECDH shared secret: expected a 32-byte Uint8Array or a 0x-prefixed 64-character hex string"
|
|
64
|
+
) {
|
|
65
|
+
super("INVALID_ECDH_SECRET", message)
|
|
66
|
+
this.name = "InvalidEcdhSecretError"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Thrown when a public key is not the correct length */
|
|
71
|
+
export class InvalidPublicKeyLengthError extends KeygenError {
|
|
72
|
+
constructor(message: string = "Invalid public key length. Expected a 65-byte uncompressed SEC1 public key") {
|
|
73
|
+
super("INVALID_PUBLIC_KEY_LENGTH", message)
|
|
74
|
+
this.name = "InvalidPublicKeyLengthError"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
2
|
+
import { concatBytes, utf8ToBytes } from "./bytes"
|
|
3
|
+
import { DerivationFailureError } from "./errors"
|
|
4
|
+
import { isValidScalar } from "./keypair"
|
|
5
|
+
|
|
6
|
+
/** A 32-byte-output hash function, e.g. `keccak_256`. */
|
|
7
|
+
export type HashFn = (input: Uint8Array) => Uint8Array
|
|
8
|
+
|
|
9
|
+
/** {@link hashToScalar} with an injectable hash, letting tests drive the reject/retry branch. */
|
|
10
|
+
export function hashToScalarWith(hashFn: HashFn, tag: string, input: Uint8Array): Uint8Array {
|
|
11
|
+
const tagBytes = utf8ToBytes(tag)
|
|
12
|
+
|
|
13
|
+
for (let counter = 0; counter <= 255; counter += 1) {
|
|
14
|
+
const candidate = hashFn(concatBytes(tagBytes, input, Uint8Array.of(counter)))
|
|
15
|
+
|
|
16
|
+
if (isValidScalar(candidate)) {
|
|
17
|
+
return candidate
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
throw new DerivationFailureError("hashToScalar: exhausted all 256 counter values without a valid scalar")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deterministically derives a secp256k1 private scalar from a domain tag and
|
|
26
|
+
* input bytes.
|
|
27
|
+
*
|
|
28
|
+
* NORMATIVE: for `counter = 0..255`, return the first
|
|
29
|
+
* `keccak256(utf8(tag) || input || [counter])` whose big-endian value lies in
|
|
30
|
+
* `[1, n)`. Out-of-range candidates have probability ~`2^-128`, so exhaustion
|
|
31
|
+
* is unreachable.
|
|
32
|
+
*/
|
|
33
|
+
export function hashToScalar(tag: string, input: Uint8Array): Uint8Array {
|
|
34
|
+
return hashToScalarWith(keccak_256, tag, input)
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@coordination-network/identity` — deterministic device key derivation
|
|
3
|
+
* (`networkId` and four secp256k1 keys from one device secret, plus a
|
|
4
|
+
* hardware-rooted Baby Jubjub EdDSA fifth key) and the credential dual
|
|
5
|
+
* proof-of-possession binding.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from "./types"
|
|
9
|
+
export * from "./constants"
|
|
10
|
+
export * from "./errors"
|
|
11
|
+
export {
|
|
12
|
+
base64urlToBytes,
|
|
13
|
+
bytesToBase64url,
|
|
14
|
+
bytesToHex,
|
|
15
|
+
hexToBytes,
|
|
16
|
+
normalizeDeviceSecret,
|
|
17
|
+
normalizeEcdhSharedSecret
|
|
18
|
+
} from "./bytes"
|
|
19
|
+
export { generateDeviceSecret } from "./device-secret"
|
|
20
|
+
export { hashToScalar, hashToScalarWith } from "./hash-to-scalar"
|
|
21
|
+
export type { HashFn } from "./hash-to-scalar"
|
|
22
|
+
export { isValidScalar, scalarToKeypair } from "./keypair"
|
|
23
|
+
export { deriveAccountKeypair, signDerivationMessage } from "./account"
|
|
24
|
+
export { deriveStealthKeys } from "./stealthAddresses/deriveStealthKeys"
|
|
25
|
+
export { computeSharedSecret } from "./stealthAddresses/computeSharedSecret"
|
|
26
|
+
export { getViewTag } from "./stealthAddresses/getViewTag"
|
|
27
|
+
export { getStealthPublicKey } from "./stealthAddresses/getStealthPublicKey"
|
|
28
|
+
export { publicKeyToAddress } from "./stealthAddresses/publicKeyToAddress"
|
|
29
|
+
export { generateEphemeralKey } from "./stealthAddresses/generateEphemeralKey"
|
|
30
|
+
export { generateStealthAddress } from "./stealthAddresses/generateStealthAddress"
|
|
31
|
+
export { generateStealthPrivateKey } from "./stealthAddresses/generateStealthPrivateKey"
|
|
32
|
+
export { checkStealthAddress } from "./stealthAddresses/checkStealthAddress"
|
|
33
|
+
export { validateMetaStealthAddress } from "./stealthAddresses/validateMetaStealthAddress"
|
|
34
|
+
export { encodeMetaStealthAddress } from "./stealthAddresses/encodeMetaStealthAddress"
|
|
35
|
+
export { encodeMetaStealthAddressUri } from "./stealthAddresses/encodeMetaStealthAddressUri"
|
|
36
|
+
export { metaStealthAddressUri } from "./stealthAddresses/metaStealthAddressUri"
|
|
37
|
+
export { decodeMetaStealthAddress } from "./stealthAddresses/decodeMetaStealthAddress"
|
|
38
|
+
export { buildAnnouncement } from "./stealthAddresses/buildAnnouncement"
|
|
39
|
+
export { deriveAuthKeypair } from "./auth"
|
|
40
|
+
export { deriveEddsaKeypair } from "./eddsa"
|
|
41
|
+
export { EDDSA_PEER_POINT_HEX, eddsaPeerPointBytes, computeEddsaPeerPoint } from "./eddsa-peer"
|
|
42
|
+
export { computeNetworkId } from "./network-id"
|
|
43
|
+
export { deriveKeys, deriveNetworkId } from "./derive"
|
|
44
|
+
export { normalizeConfirmationKey, computeConfirmationKeyHash } from "./confirmation-key"
|
|
45
|
+
export {
|
|
46
|
+
buildDeviceBindingMessage,
|
|
47
|
+
buildAccountBindingMessage,
|
|
48
|
+
buildBindingMessages,
|
|
49
|
+
signAccountBinding,
|
|
50
|
+
verifyAccountBindingSignature,
|
|
51
|
+
verifyDeviceBindingSignature,
|
|
52
|
+
verifyCredentialBinding
|
|
53
|
+
} from "./binding"
|
package/src/keypair.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1"
|
|
2
|
+
import type { Secp256k1Keypair } from "./types"
|
|
3
|
+
import { bytesToHex } from "./bytes"
|
|
4
|
+
import { DerivationFailureError } from "./errors"
|
|
5
|
+
|
|
6
|
+
const N = secp256k1.CURVE.n
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tests whether `scalar` is a valid secp256k1 private key: exactly 32 bytes
|
|
10
|
+
* encoding an integer in `[1, n)`.
|
|
11
|
+
*/
|
|
12
|
+
export function isValidScalar(scalar: Uint8Array): boolean {
|
|
13
|
+
if (scalar.length !== 32) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const value = BigInt(bytesToHex(scalar))
|
|
18
|
+
|
|
19
|
+
return value >= 1n && value < N
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Expands a 32-byte private scalar into a full {@link Secp256k1Keypair}, with
|
|
24
|
+
* all fields as lowercase `0x` hex.
|
|
25
|
+
*
|
|
26
|
+
* @throws {DerivationFailureError} if `scalar` is not in `[1, n)`
|
|
27
|
+
*/
|
|
28
|
+
export function scalarToKeypair(scalar: Uint8Array): Secp256k1Keypair {
|
|
29
|
+
if (!isValidScalar(scalar)) {
|
|
30
|
+
throw new DerivationFailureError("Derived scalar is not a valid secp256k1 private key in [1, n)")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
privateKey: bytesToHex(scalar),
|
|
35
|
+
publicKey: bytesToHex(secp256k1.getPublicKey(scalar, true)),
|
|
36
|
+
uncompressedPublicKey: bytesToHex(secp256k1.getPublicKey(scalar, false))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
2
|
+
import type { Hex } from "./types"
|
|
3
|
+
import { bytesToHex, toBytes } from "./bytes"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Computes the bytes32 `networkId` from an uncompressed secp256k1 public key
|
|
7
|
+
* (65-byte SEC1 `0x04`-prefixed or 64-byte raw `X || Y`).
|
|
8
|
+
*
|
|
9
|
+
* NORMATIVE: `networkId = keccak256(X || Y)` — the SEC1 prefix is stripped.
|
|
10
|
+
* The last 20 bytes are the account's Ethereum address.
|
|
11
|
+
*/
|
|
12
|
+
export function computeNetworkId(uncompressedPublicKey: Uint8Array | Hex): Hex {
|
|
13
|
+
const bytes = toBytes(uncompressedPublicKey)
|
|
14
|
+
|
|
15
|
+
let coordinates: Uint8Array
|
|
16
|
+
|
|
17
|
+
if (bytes.length === 65 && bytes[0] === 0x04) {
|
|
18
|
+
coordinates = bytes.slice(1)
|
|
19
|
+
} else if (bytes.length === 64) {
|
|
20
|
+
coordinates = bytes
|
|
21
|
+
} else {
|
|
22
|
+
throw new TypeError("computeNetworkId: expected a 65-byte 0x04-prefixed or 64-byte raw uncompressed public key")
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return bytesToHex(keccak_256(coordinates))
|
|
26
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1"
|
|
2
|
+
import { Hex } from "../types"
|
|
3
|
+
import { COMPRESSED_PUBLIC_KEY_LENGTH } from "../constants"
|
|
4
|
+
import { InvalidMetaStealthAddressError } from "../errors"
|
|
5
|
+
|
|
6
|
+
/** Checked per key, not on the concatenation: 32 + 34 bytes hits the right total but splits wrong. */
|
|
7
|
+
export function assertCompressedPublicKey(publicKey: Hex, label: string): void {
|
|
8
|
+
if (!/^0x[0-9a-fA-F]+$/.test(publicKey) || (publicKey.length - 2) / 2 !== COMPRESSED_PUBLIC_KEY_LENGTH) {
|
|
9
|
+
throw new InvalidMetaStealthAddressError(
|
|
10
|
+
// Never echo the value: a 32-byte private key passed here by mistake lands on this branch.
|
|
11
|
+
`${label} public key must be ${COMPRESSED_PUBLIC_KEY_LENGTH} bytes of SEC1-compressed hex`
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const prefix = publicKey.slice(2, 4)
|
|
16
|
+
if (prefix !== "02" && prefix !== "03") {
|
|
17
|
+
throw new InvalidMetaStealthAddressError(`${label} public key must start with 0x02 or 0x03, got 0x${prefix}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
secp256k1.Point.fromHex(publicKey.slice(2))
|
|
22
|
+
} catch {
|
|
23
|
+
throw new InvalidMetaStealthAddressError(`${label} public key is not a point on secp256k1`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Address, Hex } from "../types"
|
|
2
|
+
import { ERC_5564_SCHEME_ID } from "../constants"
|
|
3
|
+
import { bytesToHex } from "../bytes"
|
|
4
|
+
import { decodeMetaStealthAddress } from "./decodeMetaStealthAddress"
|
|
5
|
+
import { generateEphemeralKey } from "./generateEphemeralKey"
|
|
6
|
+
import { computeSharedSecret } from "./computeSharedSecret"
|
|
7
|
+
import { getStealthPublicKey } from "./getStealthPublicKey"
|
|
8
|
+
import { publicKeyToAddress } from "./publicKeyToAddress"
|
|
9
|
+
import { getViewTag } from "./getViewTag"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds an ERC-5564 announcement payload for a fresh stealth address,
|
|
13
|
+
* generates ephemeral key internally. The view tag is the
|
|
14
|
+
* first and only metadata byte.
|
|
15
|
+
*/
|
|
16
|
+
export function buildAnnouncement({ metaStealthAddress }: { metaStealthAddress: Hex }): {
|
|
17
|
+
schemeId: typeof ERC_5564_SCHEME_ID
|
|
18
|
+
stealthAddress: Address
|
|
19
|
+
ephemeralPublicKey: Hex
|
|
20
|
+
metadata: Hex
|
|
21
|
+
} {
|
|
22
|
+
const { spendingPublicKey, viewingPublicKey } = decodeMetaStealthAddress(metaStealthAddress)
|
|
23
|
+
const ephemeralKey = generateEphemeralKey()
|
|
24
|
+
|
|
25
|
+
const hashedSharedSecret = computeSharedSecret({
|
|
26
|
+
privateKey: ephemeralKey.privateKey,
|
|
27
|
+
publicKey: viewingPublicKey
|
|
28
|
+
})
|
|
29
|
+
const stealthPublicKey = getStealthPublicKey({ spendingPublicKey, hashedSharedSecret })
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
schemeId: ERC_5564_SCHEME_ID,
|
|
33
|
+
stealthAddress: publicKeyToAddress(stealthPublicKey),
|
|
34
|
+
ephemeralPublicKey: ephemeralKey.publicKey,
|
|
35
|
+
metadata: bytesToHex(Uint8Array.of(getViewTag(hashedSharedSecret)))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Address, Hex } from "../types"
|
|
2
|
+
import { computeSharedSecret } from "./computeSharedSecret"
|
|
3
|
+
import { getViewTag } from "./getViewTag"
|
|
4
|
+
import { getStealthPublicKey } from "./getStealthPublicKey"
|
|
5
|
+
import { publicKeyToAddress } from "./publicKeyToAddress"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks whether an announcement is addressed to a recipient: filters by
|
|
9
|
+
* view-tag first, then full address recompute.
|
|
10
|
+
*/
|
|
11
|
+
export function checkStealthAddress({
|
|
12
|
+
stealthAddress,
|
|
13
|
+
ephemeralPublicKey,
|
|
14
|
+
spendingPublicKey,
|
|
15
|
+
viewingPrivateKey,
|
|
16
|
+
viewTag
|
|
17
|
+
}: {
|
|
18
|
+
stealthAddress: Address
|
|
19
|
+
ephemeralPublicKey: Hex
|
|
20
|
+
spendingPublicKey: Hex
|
|
21
|
+
viewingPrivateKey: Hex
|
|
22
|
+
viewTag: number
|
|
23
|
+
}): boolean {
|
|
24
|
+
const hashedSharedSecret = computeSharedSecret({
|
|
25
|
+
privateKey: viewingPrivateKey,
|
|
26
|
+
publicKey: ephemeralPublicKey
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (viewTag !== getViewTag(hashedSharedSecret)) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stealthPublicKey = getStealthPublicKey({ spendingPublicKey, hashedSharedSecret })
|
|
34
|
+
const expectedStealthAddress = publicKeyToAddress(stealthPublicKey)
|
|
35
|
+
|
|
36
|
+
return expectedStealthAddress.toLowerCase() === stealthAddress.toLowerCase()
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1"
|
|
2
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
3
|
+
import { Hex } from "../types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ERC-5564 hashed shared secret: keccak256 of the 33-byte compressed
|
|
7
|
+
* ECDH point.
|
|
8
|
+
*
|
|
9
|
+
* The sender can pass (ephemeralPrivateKey, viewingPublicKey).
|
|
10
|
+
* The recipient can pass (viewingPrivateKey, ephemeralPublicKey).
|
|
11
|
+
*/
|
|
12
|
+
export function computeSharedSecret({ privateKey, publicKey }: { privateKey: Hex; publicKey: Hex }): Uint8Array {
|
|
13
|
+
const sharedSecret = secp256k1.getSharedSecret(privateKey.slice(2), publicKey.slice(2), true)
|
|
14
|
+
|
|
15
|
+
return keccak_256(sharedSecret)
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hex } from "../types"
|
|
2
|
+
import { META_STEALTH_URI_PREFIX } from "../constants"
|
|
3
|
+
import { validateMetaStealthAddress } from "./validateMetaStealthAddress"
|
|
4
|
+
import { InvalidMetaStealthAddressError } from "../errors"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Splits a 66-byte meta-stealth address (raw hex or `st:eth:` prefixed) into its
|
|
8
|
+
* spending and viewing public keys.
|
|
9
|
+
*/
|
|
10
|
+
export function decodeMetaStealthAddress(metaStealthAddress: string): {
|
|
11
|
+
spendingPublicKey: Hex
|
|
12
|
+
viewingPublicKey: Hex
|
|
13
|
+
} {
|
|
14
|
+
const hexMetaStealthAddress = metaStealthAddress.startsWith(META_STEALTH_URI_PREFIX)
|
|
15
|
+
? metaStealthAddress.slice(META_STEALTH_URI_PREFIX.length)
|
|
16
|
+
: metaStealthAddress
|
|
17
|
+
|
|
18
|
+
if (!validateMetaStealthAddress(hexMetaStealthAddress)) {
|
|
19
|
+
throw new InvalidMetaStealthAddressError()
|
|
20
|
+
}
|
|
21
|
+
const cleanedMetaStealthAddress = hexMetaStealthAddress.slice(2)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
spendingPublicKey: `0x${cleanedMetaStealthAddress.slice(0, 66)}`,
|
|
25
|
+
viewingPublicKey: `0x${cleanedMetaStealthAddress.slice(66)}`
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
2
|
+
import type { Hex, Secp256k1Keypair } from "../types"
|
|
3
|
+
import { normalizeSignature64 } from "../account"
|
|
4
|
+
import { scalarToKeypair } from "../keypair"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derives the spending and viewing secp256k1 keypairs from the 64-byte
|
|
8
|
+
* compact derivation-message signature.
|
|
9
|
+
*
|
|
10
|
+
* NORMATIVE (FluidKey `generateKeysFromSignature` semantics): with
|
|
11
|
+
* `r = sig[0..32]` and `s = sig[32..64]`, `spendingPrivKey = keccak256(r)`
|
|
12
|
+
* and `viewingPrivKey = keccak256(s)`. No reject/retry — the hash output is
|
|
13
|
+
* used directly and validated to be in `[1, n)` by {@link scalarToKeypair}.
|
|
14
|
+
*
|
|
15
|
+
* `sig64` is unauthenticated — prefer {@link deriveKeys} (see `DERIVATION.md`).
|
|
16
|
+
*/
|
|
17
|
+
export function deriveStealthKeys(sig64: Uint8Array | Hex): {
|
|
18
|
+
spending: Secp256k1Keypair
|
|
19
|
+
viewing: Secp256k1Keypair
|
|
20
|
+
} {
|
|
21
|
+
const signature = normalizeSignature64(sig64)
|
|
22
|
+
|
|
23
|
+
const r = signature.slice(0, 32)
|
|
24
|
+
const s = signature.slice(32, 64)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
spending: scalarToKeypair(keccak_256(r)),
|
|
28
|
+
viewing: scalarToKeypair(keccak_256(s))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Hex } from "../types"
|
|
2
|
+
import { assertCompressedPublicKey } from "./assertCompressedPublicKey"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The 66-byte compressed spending || viewing concatenation the onchain registry stores. Throws
|
|
6
|
+
* InvalidMetaStealthAddressError, naming the offending key, so it can be used before registering.
|
|
7
|
+
*/
|
|
8
|
+
export function encodeMetaStealthAddress({
|
|
9
|
+
spendingPublicKey,
|
|
10
|
+
viewingPublicKey
|
|
11
|
+
}: {
|
|
12
|
+
spendingPublicKey: Hex
|
|
13
|
+
viewingPublicKey: Hex
|
|
14
|
+
}): Hex {
|
|
15
|
+
assertCompressedPublicKey(spendingPublicKey, "spending")
|
|
16
|
+
assertCompressedPublicKey(viewingPublicKey, "viewing")
|
|
17
|
+
|
|
18
|
+
return `${spendingPublicKey}${viewingPublicKey.slice(2)}`
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Hex } from "../types"
|
|
2
|
+
import { encodeMetaStealthAddress } from "./encodeMetaStealthAddress"
|
|
3
|
+
import { metaStealthAddressUri } from "./metaStealthAddressUri"
|
|
4
|
+
|
|
5
|
+
/** The st:eth:0x… display form (ERC-5564 URI). Not stored onchain. */
|
|
6
|
+
export function encodeMetaStealthAddressUri(publicKeys: { spendingPublicKey: Hex; viewingPublicKey: Hex }): string {
|
|
7
|
+
return metaStealthAddressUri(encodeMetaStealthAddress(publicKeys))
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { scalarToKeypair } from "../keypair"
|
|
2
|
+
import { hexToBytes } from "../bytes"
|
|
3
|
+
import { Secp256k1Keypair } from "../types"
|
|
4
|
+
import { generateDeviceSecret } from "../device-secret"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A fresh CSPRNG keypair for one load. MUST be single-use — reusing an
|
|
8
|
+
* ephemeral key derives the same stealth address twice.
|
|
9
|
+
*/
|
|
10
|
+
export function generateEphemeralKey(): Secp256k1Keypair {
|
|
11
|
+
return scalarToKeypair(hexToBytes(generateDeviceSecret()))
|
|
12
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Address, Hex } from "../types"
|
|
2
|
+
import { computeSharedSecret } from "./computeSharedSecret"
|
|
3
|
+
import { getStealthPublicKey } from "./getStealthPublicKey"
|
|
4
|
+
import { publicKeyToAddress } from "./publicKeyToAddress"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Derives a fresh stealth address from the
|
|
8
|
+
* recipient's spending and viewing public keys.
|
|
9
|
+
*/
|
|
10
|
+
export function generateStealthAddress({
|
|
11
|
+
viewingPublicKey,
|
|
12
|
+
spendingPublicKey,
|
|
13
|
+
ephemeralPrivateKey
|
|
14
|
+
}: {
|
|
15
|
+
viewingPublicKey: Hex
|
|
16
|
+
spendingPublicKey: Hex
|
|
17
|
+
ephemeralPrivateKey: Hex
|
|
18
|
+
}): Address {
|
|
19
|
+
const hashedSharedSecret = computeSharedSecret({
|
|
20
|
+
privateKey: ephemeralPrivateKey,
|
|
21
|
+
publicKey: viewingPublicKey
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const stealthPublicKey = getStealthPublicKey({ spendingPublicKey, hashedSharedSecret })
|
|
25
|
+
|
|
26
|
+
return publicKeyToAddress(stealthPublicKey)
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1"
|
|
2
|
+
import { bytesToNumberBE, numberToBytesBE } from "@noble/curves/utils"
|
|
3
|
+
import { Hex } from "../types"
|
|
4
|
+
import { bytesToHex, hexToBytes } from "../bytes"
|
|
5
|
+
import { computeSharedSecret } from "./computeSharedSecret"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recovers the private key controlling a stealth address,
|
|
9
|
+
* (p_spend + s_h) mod n.
|
|
10
|
+
*/
|
|
11
|
+
export function generateStealthPrivateKey({
|
|
12
|
+
ephemeralPublicKey,
|
|
13
|
+
viewingPrivateKey,
|
|
14
|
+
spendingPrivateKey
|
|
15
|
+
}: {
|
|
16
|
+
ephemeralPublicKey: Hex
|
|
17
|
+
viewingPrivateKey: Hex
|
|
18
|
+
spendingPrivateKey: Hex
|
|
19
|
+
}): Hex {
|
|
20
|
+
const secretHash = computeSharedSecret({
|
|
21
|
+
privateKey: viewingPrivateKey,
|
|
22
|
+
publicKey: ephemeralPublicKey
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const privateKey =
|
|
26
|
+
(bytesToNumberBE(hexToBytes(spendingPrivateKey)) + bytesToNumberBE(secretHash)) % secp256k1.Point.CURVE().n
|
|
27
|
+
|
|
28
|
+
return bytesToHex(numberToBytesBE(privateKey, 32))
|
|
29
|
+
}
|