@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/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
+ }