@coordination-network/user-client 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/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/index.cjs +446 -0
- package/dist/index.js +412 -0
- package/package.json +57 -0
- package/src/bytes.ts +52 -0
- package/src/constants.ts +17 -0
- package/src/eip712.ts +57 -0
- package/src/errors.ts +40 -0
- package/src/http-submitter.ts +151 -0
- package/src/index.ts +13 -0
- package/src/member-server.ts +26 -0
- package/src/meta.ts +13 -0
- package/src/register.ts +70 -0
- package/src/registry.ts +32 -0
- package/src/request.ts +89 -0
- package/src/types.ts +71 -0
- package/types/index.d.ts +185 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ethereum Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# `@coordination-network/user-client`
|
|
2
|
+
|
|
3
|
+
Single-call onboarding and registration for the coordination network: derive a device's keys via `@coordination-network/identity`, encode the ERC-6538 meta-stealth address, and hand a signed registration request to the network member's server, which writes `networkId -> metaStealthAddress` on `IdentityRegistry`, deduped against an already-registered identity.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
yarn add @coordination-network/user-client
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { onboard, register, InMemoryIdentityRegistry, InMemoryMemberServer } from "@coordination-network/user-client"
|
|
15
|
+
|
|
16
|
+
// Derive this device's keys, networkId, and meta-stealth address.
|
|
17
|
+
const { networkId, displayMetaStealthAddress } = onboard(deviceSecret)
|
|
18
|
+
|
|
19
|
+
console.log(networkId) // 0x-prefixed bytes32
|
|
20
|
+
console.log(displayMetaStealthAddress) // "st:eth:0x..."
|
|
21
|
+
|
|
22
|
+
// InMemoryIdentityRegistry and InMemoryMemberServer are test/demo fakes standing in for the
|
|
23
|
+
// registry's read model and the network member's server. A real deployment uses
|
|
24
|
+
// network-member-client's IdentityRegistryClient as the RegistryReader and an HTTP client for the
|
|
25
|
+
// member server's registration endpoint as the RegistrationSubmitter — see "Dependency-injection
|
|
26
|
+
// seams" below.
|
|
27
|
+
const registry = new InMemoryIdentityRegistry()
|
|
28
|
+
const memberServer = new InMemoryMemberServer(registry, { chainId: 1, verifyingContract: "0x..." })
|
|
29
|
+
|
|
30
|
+
const result = await register({
|
|
31
|
+
deviceSecret,
|
|
32
|
+
registry,
|
|
33
|
+
submitter: memberServer,
|
|
34
|
+
domain: { chainId: 1, verifyingContract: "0x..." }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if (result.status === "registered") {
|
|
38
|
+
console.log(result.receipt.txHash)
|
|
39
|
+
} else {
|
|
40
|
+
console.log("already registered as", result.metaStealthAddress)
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API reference
|
|
45
|
+
|
|
46
|
+
| Export | Kind | Description | Throws |
|
|
47
|
+
| --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
|
|
48
|
+
| `onboard` | function | Derives keys from a device secret and returns `{ keys, networkId, metaStealthAddress, displayMetaStealthAddress }`. | `InvalidMetaStealthAddressError` if the derived spending/viewing public keys are malformed. |
|
|
49
|
+
| `register` | function | Full flow: `onboard`, check for an existing registration, and submit if none exists. | `SubmissionFailedError`, `RegistryReadFailedError`, or `UserClientError` (`INVALID_INPUT`) on a malformed reader answer. |
|
|
50
|
+
| `metaStealthAddressFromKeys` | function | Encodes a `DerivedKeys` bundle's spending/viewing keys into the 66-byte ERC-6538 scheme-1 meta-stealth address. Delegates to `@coordination-network/identity`. | `InvalidMetaStealthAddressError` on a malformed spending/viewing public key. |
|
|
51
|
+
| `displayMetaStealthAddress` | function | Re-exported from `@coordination-network/identity` as `metaStealthAddressUri`. Formats a meta-stealth address hex string with the `st:eth:` display prefix. | — |
|
|
52
|
+
| `identityRegistryDomainSeparator` | function | Computes the EIP-712 domain separator naming the `IdentityRegistry` deployment the consent is bound to. | — |
|
|
53
|
+
| `registerIdentityStructHash` | function | Computes the EIP-712 `RegisterIdentity` struct hash for a `networkId`/`metaStealthAddress` pair. | — |
|
|
54
|
+
| `registerIdentityDigest` | function | Computes the final EIP-712 digest (`keccak256(0x1901 \|\| domainSeparator \|\| structHash)`) signed and verified by the registration flow. | — |
|
|
55
|
+
| `signRegisterIdentity` | function | Signs a digest with the account private key, returning a 65-byte `r \|\| s \|\| v` hex signature. | — |
|
|
56
|
+
| `buildRegistrationRequest` | function | Builds a fully-signed `RegistrationRequest` from a `DerivedKeys` bundle and a `ChainDomain`. | `InvalidMetaStealthAddressError` (via `metaStealthAddressFromKeys`). |
|
|
57
|
+
| `verifyRegistrationRequest` | function | Whether a member server pinned to `expected` should accept the request: field widths, a well-formed meta-stealth address, the domain, and a signer matching the full `networkId`. Returns `boolean`, never throws. | — |
|
|
58
|
+
| `InMemoryIdentityRegistry` | class | In-memory `RegistryReader` fake for tests/local dev. `registerIdentity` is write-once. | `registerIdentity` throws `SubmissionFailedError` (`InvalidMetaStealthAddressLength` or `AlreadyRegistered`). |
|
|
59
|
+
| `InMemoryMemberServer` | class | In-memory `RegistrationSubmitter` fake standing in for an approved network member's server. | `submit` throws `SubmissionFailedError` on `InvalidRegistrationRequest` or `AlreadyRegistered`. |
|
|
60
|
+
| `createHttpRegistrationSubmitter` | function | Builds a `RegistrationSubmitter` that POSTs to a member server's `/v1/registrations` endpoint over `fetch`. | `HttpSubmissionError` for every non-submitted response and on a network failure. |
|
|
61
|
+
| `HttpSubmissionError` | class | Extends `SubmissionFailedError`. Carries `status`, `reason` (`"alreadyRegistered" \| "rejected" \| "unavailable" \| "network" \| "invalidResponse"`), and, where the server sent them, `serverCode`/`metaStealthAddress`/`matchesRequest`. | — |
|
|
62
|
+
| `UserClientError` | class | Base error class for this package, discriminated by `code: UserClientErrorCode`. | — |
|
|
63
|
+
| `InvalidMetaStealthAddressError` | class | Re-exported from `@coordination-network/identity`. Extends its `KeygenError`, not `UserClientError`, and names the malformed key. Thrown when encoding a meta-stealth address. | — |
|
|
64
|
+
| `SubmissionFailedError` | class | Extends `UserClientError` (`code: "SUBMISSION_FAILED"`). Thrown when a `RegistrationSubmitter.submit` call fails, including on a lost duplicate-registration race (see below). | — |
|
|
65
|
+
| `RegistryReadFailedError` | class | Extends `UserClientError` (`code: "REGISTRY_READ_FAILED"`). Thrown when a `RegistryReader.getIdentity` call rejects. | — |
|
|
66
|
+
|
|
67
|
+
## Meta-stealth-address encoding
|
|
68
|
+
|
|
69
|
+
`metaStealthAddressFromKeys`, in `src/meta.ts`, adapts a `DerivedKeys` bundle onto `@coordination-network/identity`'s `encodeMetaStealthAddress`; `displayMetaStealthAddress` is that package's `metaStealthAddressUri` under its local name. The encoding itself — and the validation behind it — lives in that package. The frozen conformance vectors in `tests/vectors/registration-vectors.json` pinned the encoding across that swap: every vector value is unchanged, which is what proves the change was byte-for-byte (only the `scheme.algorithm` description has been reworded since):
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
yarn tsx packages/user-client/tests/generate-vectors.ts
|
|
73
|
+
git diff --exit-code packages/user-client/tests/vectors/registration-vectors.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Dependency-injection seams: `RegistryReader` / `RegistrationSubmitter`
|
|
77
|
+
|
|
78
|
+
`verifyRegistrationRequest` takes the domain you expect as its second argument. A request carries its own `chainId` and `verifyingContract`, and neither is covered by the signature, so verifying against the request's own domain would only prove it is self-consistent — a signature made for another chain or another registry would pass. Pass the domain your registry is deployed at.
|
|
79
|
+
|
|
80
|
+
**Implementing `RegistryReader` against a chain.** `IdentityRegistry` stores the mapping as `bytes`, so its generated getter answers an unregistered `networkId` with empty bytes. viem and ethers surface that as `"0x"`, and the port requires `null`. `register()` rejects anything that is neither `null` nor a 66-byte entry rather than reading it as an existing registration, but an adapter should map the empty answer to `null` itself.
|
|
81
|
+
|
|
82
|
+
`RegistryReader` (read an identity's current registration) and `RegistrationSubmitter` (submit a new one) are dependency-injection interfaces, not concrete transports. On chain, writes are gated on an approved network member's Semaphore proof (`IdentityRegistry.registerIdentity(networkId, metaStealthAddress, proof)`; the proof comes from `@coordination-network/network-member-proof`). The user's signature is consent the member server verifies off chain with `verifyRegistrationRequest` before it builds the proof and submits; the contract never reads it. No credential check sits on this path (#46). The gas-sponsorship model is settled ([`specs/05-stealth-addresses.md`](../../specs/05-stealth-addresses.md), "Gas sponsorship"): registration stays self-submitted and self-paid by the network-member server, and only post-registration transactions go through the shared amortised Semaphore paymaster. Any future transport — a relayer, a meta-transaction relay, a bundler — plugs in as a new `RegistrationSubmitter` implementation without changing this package's API. `InMemoryIdentityRegistry` and `InMemoryMemberServer` are in-memory fakes for tests and local development; `@coordination-network/network-member-client`'s `IdentityRegistryClient` satisfies `RegistryReader` (it maps the getter's empty bytes to `null`); `createHttpRegistrationSubmitter` (below) is the real `RegistrationSubmitter`, an HTTP client for `apps/issuer-service`'s `POST /v1/registrations`, which verifies consent with `verifyRegistrationRequest` and submits it via `network-member-client`'s `submitRegistration`.
|
|
83
|
+
|
|
84
|
+
### HTTP submitter
|
|
85
|
+
|
|
86
|
+
`createHttpRegistrationSubmitter({ url })` POSTs the `RegistrationRequest` as JSON to `${url}/v1/registrations` and maps the member server's response onto this package's types:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { createHttpRegistrationSubmitter } from "@coordination-network/user-client"
|
|
90
|
+
|
|
91
|
+
const submitter = createHttpRegistrationSubmitter({ url: "https://member.example" })
|
|
92
|
+
const result = await register({ deviceSecret, registry, submitter, domain })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A `201` resolves `submit()` with `{ txHash }`. A `200` (the member server's `alreadyRegistered` response) throws `HttpSubmissionError` (`reason: "alreadyRegistered"`) carrying the registry's current `metaStealthAddress`; catch it and re-run `register()` — its own `registry.getIdentity` read will now see that entry and return the `alreadyRegistered` result instead. `400`, `503`, and a network failure throw `HttpSubmissionError` with `reason: "rejected"`, `"unavailable"` (with the server's own `serverCode`, if it sent one), and `"network"`, respectively. `HttpSubmissionError` never includes the request body or its signature.
|
|
96
|
+
|
|
97
|
+
## Security notes
|
|
98
|
+
|
|
99
|
+
- **`status: "registered"` means submitted, not confirmed.** `register()` returns as soon as the injected `RegistrationSubmitter` resolves. It does not wait for inclusion or check a receipt, and `receipt.txHash` is `null` for a submitter that queues rather than broadcasts. A transaction that is mined and reverts also resolves here.
|
|
100
|
+
- **An existing entry is reported, not verified, and can change.** Only `registerIdentity` refuses a duplicate; an approved network member can `updateIdentity` or `removeIdentity` any entry at any time. If `alreadyRegistered` comes back with `matchesDerived: false`, this device's keys do not derive the meta-stealth address its `networkId` currently resolves to, and that is recoverable only through a member calling `updateIdentity`. A `matchesDerived: true` result is not permanent either — re-read `getIdentity` and compare against `metaStealthAddressFromKeys` whenever the mapping is load-bearing.
|
|
101
|
+
- **Consent is checked by the member server, not the chain.** `IdentityRegistry` gates writes on a Semaphore proof, not on the user's signature. A member server that skips `verifyRegistrationRequest` can register any `metaStealthAddress` under any `networkId`, since its membership proof covers whatever pair it submits. Comparing `metaStealthAddress` with `derivedMetaStealthAddress` (the bullet above) is how a user detects that.
|
|
102
|
+
- **Duplicate-registration races.** If two `register()` calls for the same `networkId` are in flight at once, both will see no existing registration and both will attempt `submit()`. The losing submission surfaces as a `SubmissionFailedError` (`AlreadyRegistered`, in `InMemoryMemberServer`'s case). The caller should simply retry `register()`: the retry's `registry.getIdentity` lookup will now find the winner's registration and return the `alreadyRegistered` result instead of erroring.
|
|
103
|
+
- **React Native / Hermes.** `@coordination-network/identity`'s barrel loads on Hermes without a global `Buffer` — `onboard` and `register` don't need one. An app that also depends on something else expecting Node's `Buffer` may still want to set a polyfill globally for that code, but this package's own path does not require it.
|
|
104
|
+
|
|
105
|
+
## Integration test
|
|
106
|
+
|
|
107
|
+
`tests/integration` spawns `anvil`, deploys `SemaphoreVerifier`, `Semaphore`, and `IdentityRegistry` from the forge artifacts, and runs `register()` against the real contract with `IdentityRegistryClient` as the reader. It needs `anvil` on `PATH` and `yarn build:contracts` beforehand, and runs as part of `yarn test`; without `anvil` it is skipped locally and fails in CI.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT, see [`LICENSE`](./LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var identity = require('@coordination-network/identity');
|
|
4
|
+
var sha3 = require('@noble/hashes/sha3');
|
|
5
|
+
var utils = require('@noble/hashes/utils');
|
|
6
|
+
var secp256k1 = require('@noble/curves/secp256k1');
|
|
7
|
+
|
|
8
|
+
/** Base error class for all errors thrown by this package, discriminated by `code`. */
|
|
9
|
+
class UserClientError extends Error {
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "UserClientError";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
class SubmissionFailedError extends UserClientError {
|
|
17
|
+
constructor(message, cause) {
|
|
18
|
+
super("SUBMISSION_FAILED", message);
|
|
19
|
+
this.name = "SubmissionFailedError";
|
|
20
|
+
if (cause !== undefined) {
|
|
21
|
+
// Non-enumerable, matching the standard Error.cause. Provider errors hold circular
|
|
22
|
+
// transport graphs, so an enumerable copy makes JSON.stringify throw inside a
|
|
23
|
+
// caller's error handler and drags the provider's URL into its logs.
|
|
24
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
class RegistryReadFailedError extends UserClientError {
|
|
29
|
+
constructor(message, cause) {
|
|
30
|
+
super("REGISTRY_READ_FAILED", message);
|
|
31
|
+
this.name = "RegistryReadFailedError";
|
|
32
|
+
if (cause !== undefined) {
|
|
33
|
+
Object.defineProperty(this, "cause", { value: cause, writable: true, configurable: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** ERC-6538 scheme 1 (secp256k1 stealth addresses), the schemeId field of the consent struct. */
|
|
39
|
+
const SCHEME_ID = 1;
|
|
40
|
+
/** networkId is a bytes32 on chain, and abi.encode splices it in as one word. */
|
|
41
|
+
const NETWORK_ID_LENGTH = 32;
|
|
42
|
+
/** r || s || v, the 65-byte Ethereum signature layout. */
|
|
43
|
+
const SIGNATURE_LENGTH = 65;
|
|
44
|
+
/** keccak256 output, the only thing this package will sign. */
|
|
45
|
+
const DIGEST_LENGTH = 32;
|
|
46
|
+
/** Ethereum transaction hash, as HttpSubmissionError's 201 branch expects it. */
|
|
47
|
+
const TX_HASH_LENGTH = 32;
|
|
48
|
+
/** Wire-format version of RegistrationRequest. */
|
|
49
|
+
const REQUEST_VERSION = "v1";
|
|
50
|
+
const IDENTITY_REGISTRY_DOMAIN_NAME = "IdentityRegistry";
|
|
51
|
+
const IDENTITY_REGISTRY_DOMAIN_VERSION = "1";
|
|
52
|
+
/** Signed by the user as consent to register; the member server verifies it, the contract never reads it. */
|
|
53
|
+
const REGISTER_IDENTITY_TYPE = "RegisterIdentity(bytes32 networkId,uint256 schemeId,bytes metaStealthAddress)";
|
|
54
|
+
const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
|
|
55
|
+
|
|
56
|
+
/** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
|
|
57
|
+
function metaStealthAddressFromKeys(keys) {
|
|
58
|
+
return identity.encodeMetaStealthAddress({
|
|
59
|
+
spendingPublicKey: keys.spending.publicKey,
|
|
60
|
+
viewingPublicKey: keys.viewing.publicKey
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parses a 0x-prefixed hex string into bytes. Throws on missing prefix, odd length, or non-hex chars. */
|
|
65
|
+
function hexToBytes(hex) {
|
|
66
|
+
if (typeof hex !== "string" || !hex.startsWith("0x")) {
|
|
67
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 0x-prefixed hex string, got ${typeof hex}`);
|
|
68
|
+
}
|
|
69
|
+
const body = hex.slice(2);
|
|
70
|
+
if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
|
|
71
|
+
throw new UserClientError("INVALID_INPUT", `Malformed hex string of length ${body.length}`);
|
|
72
|
+
}
|
|
73
|
+
return utils.hexToBytes(body);
|
|
74
|
+
}
|
|
75
|
+
function bytesToHex(bytes) {
|
|
76
|
+
return `0x${utils.bytesToHex(bytes)}`;
|
|
77
|
+
}
|
|
78
|
+
function concatBytes(...arrays) {
|
|
79
|
+
return utils.concatBytes(...arrays);
|
|
80
|
+
}
|
|
81
|
+
/** Big-endian 32-byte word encoding of a non-negative safe-integer uint256, as abi.encode would produce. */
|
|
82
|
+
function numberToWord(value) {
|
|
83
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
84
|
+
throw new UserClientError("INVALID_INPUT", `Expected a non-negative safe integer, got ${typeof value} ${value}`);
|
|
85
|
+
}
|
|
86
|
+
const word = new Uint8Array(32);
|
|
87
|
+
let remaining = BigInt(value);
|
|
88
|
+
for (let i = 31; i >= 0 && remaining > 0n; i -= 1) {
|
|
89
|
+
word[i] = Number(remaining & 0xffn);
|
|
90
|
+
remaining >>= 8n;
|
|
91
|
+
}
|
|
92
|
+
return word;
|
|
93
|
+
}
|
|
94
|
+
/** Left-pads a 20-byte address into a 32-byte word, as abi.encode would produce. */
|
|
95
|
+
function addressToWord(address) {
|
|
96
|
+
const addressBytes = hexToBytes(address);
|
|
97
|
+
if (addressBytes.length !== 20) {
|
|
98
|
+
throw new UserClientError("INVALID_INPUT", `Expected a 20-byte address, got ${addressBytes.length} bytes`);
|
|
99
|
+
}
|
|
100
|
+
const word = new Uint8Array(32);
|
|
101
|
+
word.set(addressBytes, 12);
|
|
102
|
+
return word;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const EIP712_DOMAIN_TYPEHASH = sha3.keccak_256(utils.utf8ToBytes(EIP712_DOMAIN_TYPE));
|
|
106
|
+
const REGISTER_IDENTITY_TYPEHASH = sha3.keccak_256(utils.utf8ToBytes(REGISTER_IDENTITY_TYPE));
|
|
107
|
+
/** Every input is already a 32-byte word, so concatenating them is what abi.encode would emit. */
|
|
108
|
+
function hashEncodedWords(...words) {
|
|
109
|
+
return sha3.keccak_256(concatBytes(...words));
|
|
110
|
+
}
|
|
111
|
+
function identityRegistryDomainSeparator(domain) {
|
|
112
|
+
const nameHash = sha3.keccak_256(utils.utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_NAME));
|
|
113
|
+
const versionHash = sha3.keccak_256(utils.utf8ToBytes(IDENTITY_REGISTRY_DOMAIN_VERSION));
|
|
114
|
+
return bytesToHex(hashEncodedWords(EIP712_DOMAIN_TYPEHASH, nameHash, versionHash, numberToWord(domain.chainId), addressToWord(domain.verifyingContract)));
|
|
115
|
+
}
|
|
116
|
+
function registerIdentityStructHash(networkId, metaStealthAddress) {
|
|
117
|
+
const networkIdWord = hexToBytes(networkId);
|
|
118
|
+
if (networkIdWord.length !== NETWORK_ID_LENGTH) {
|
|
119
|
+
throw new UserClientError("INVALID_INPUT", `Expected a ${NETWORK_ID_LENGTH}-byte networkId, got ${networkIdWord.length} bytes`);
|
|
120
|
+
}
|
|
121
|
+
const metaHash = sha3.keccak_256(hexToBytes(metaStealthAddress));
|
|
122
|
+
return bytesToHex(hashEncodedWords(REGISTER_IDENTITY_TYPEHASH, networkIdWord, numberToWord(SCHEME_ID), metaHash));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The digest the user signs as consent to register this meta-stealth address at this registry on
|
|
126
|
+
* this chain. The member server verifies it before submitting; the contract never reads it.
|
|
127
|
+
*/
|
|
128
|
+
function registerIdentityDigest(domain, networkId, metaStealthAddress) {
|
|
129
|
+
const domainSeparator = hexToBytes(identityRegistryDomainSeparator(domain));
|
|
130
|
+
const structHash = hexToBytes(registerIdentityStructHash(networkId, metaStealthAddress));
|
|
131
|
+
return bytesToHex(sha3.keccak_256(concatBytes(Uint8Array.from([0x19, 0x01]), domainSeparator, structHash)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
|
|
135
|
+
function signRegisterIdentity(accountPrivateKey, digest) {
|
|
136
|
+
const digestBytes = hexToBytes(digest);
|
|
137
|
+
if (digestBytes.length !== DIGEST_LENGTH) {
|
|
138
|
+
// Exported, so refuse to sign anything that is not a digest this package produced.
|
|
139
|
+
throw new UserClientError("INVALID_INPUT", `Expected a ${DIGEST_LENGTH}-byte digest, got ${digestBytes.length}`);
|
|
140
|
+
}
|
|
141
|
+
const signature = secp256k1.secp256k1.sign(digestBytes, hexToBytes(accountPrivateKey), { lowS: true });
|
|
142
|
+
return bytesToHex(concatBytes(signature.toCompactRawBytes(), Uint8Array.from([27 + signature.recovery])));
|
|
143
|
+
}
|
|
144
|
+
function buildRegistrationRequest(keys, domain) {
|
|
145
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys);
|
|
146
|
+
const digest = registerIdentityDigest(domain, keys.networkId, metaStealthAddress);
|
|
147
|
+
return {
|
|
148
|
+
version: REQUEST_VERSION,
|
|
149
|
+
networkId: keys.networkId,
|
|
150
|
+
schemeId: SCHEME_ID,
|
|
151
|
+
metaStealthAddress,
|
|
152
|
+
chainId: domain.chainId,
|
|
153
|
+
verifyingContract: domain.verifyingContract,
|
|
154
|
+
signature: signRegisterIdentity(keys.account.privateKey, digest)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Whether a member server pinned to `expected` should accept the request: field widths, a
|
|
159
|
+
* well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
|
|
160
|
+
*
|
|
161
|
+
* `expected` is required because the domain inside a request is attacker-controlled — verifying
|
|
162
|
+
* against it would only prove the request is self-consistent, not that it targets this registry.
|
|
163
|
+
*
|
|
164
|
+
* Returns false rather than throwing, so an untrusted request can be passed in as-is.
|
|
165
|
+
*/
|
|
166
|
+
function verifyRegistrationRequest(request, expected) {
|
|
167
|
+
try {
|
|
168
|
+
if (request.version !== REQUEST_VERSION || request.schemeId !== SCHEME_ID)
|
|
169
|
+
return false;
|
|
170
|
+
// Neither field is covered by the signature, so both have to be pinned to the caller's own
|
|
171
|
+
// registry. Otherwise a signature made for another chain or contract replays here.
|
|
172
|
+
if (request.chainId !== expected.chainId)
|
|
173
|
+
return false;
|
|
174
|
+
if (request.verifyingContract.toLowerCase() !== expected.verifyingContract.toLowerCase())
|
|
175
|
+
return false;
|
|
176
|
+
// The contract types these as bytes32 and a 66-byte bytes. A request signed self-consistently
|
|
177
|
+
// over the wrong width verifies against itself but cannot be submitted.
|
|
178
|
+
const networkIdBytes = hexToBytes(request.networkId);
|
|
179
|
+
if (networkIdBytes.length !== NETWORK_ID_LENGTH)
|
|
180
|
+
return false;
|
|
181
|
+
if (hexToBytes(request.metaStealthAddress).length !== identity.META_STEALTH_ADDRESS_LENGTH)
|
|
182
|
+
return false;
|
|
183
|
+
// Width alone admits 66 bytes the contract would store but no one could ever pay to.
|
|
184
|
+
if (!identity.validateMetaStealthAddress(request.metaStealthAddress))
|
|
185
|
+
return false;
|
|
186
|
+
const signatureBytes = hexToBytes(request.signature);
|
|
187
|
+
if (signatureBytes.length !== SIGNATURE_LENGTH)
|
|
188
|
+
return false;
|
|
189
|
+
const rs = signatureBytes.slice(0, 64);
|
|
190
|
+
const recovery = signatureBytes[64] - 27;
|
|
191
|
+
if (recovery !== 0 && recovery !== 1)
|
|
192
|
+
return false;
|
|
193
|
+
const digest = registerIdentityDigest(expected, request.networkId, request.metaStealthAddress);
|
|
194
|
+
const signature = secp256k1.secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery);
|
|
195
|
+
// Signing uses `lowS` (EIP-2), so verification rejects high-S to agree with it.
|
|
196
|
+
if (signature.hasHighS())
|
|
197
|
+
return false;
|
|
198
|
+
const recoveredPublicKey = signature.recoverPublicKey(hexToBytes(digest)).toBytes(false);
|
|
199
|
+
// computeNetworkId is keccak256(X || Y); compare all 32 bytes. An address-style check over the
|
|
200
|
+
// low 20 would leave the top 12 unauthenticated and let one key claim 2^96 ids. The contract
|
|
201
|
+
// itself checks no signer at all.
|
|
202
|
+
return identity.computeNetworkId(recoveredPublicKey).toLowerCase() === request.networkId.toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* In-memory stand-in for the registry's read model and the two reverts on its register path,
|
|
211
|
+
* length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
|
|
212
|
+
* asserts both reverts against the real contract and this fake side by side.
|
|
213
|
+
*/
|
|
214
|
+
class InMemoryIdentityRegistry {
|
|
215
|
+
constructor() {
|
|
216
|
+
this.identities = new Map();
|
|
217
|
+
}
|
|
218
|
+
registerIdentity(networkId, metaStealthAddress) {
|
|
219
|
+
const length = hexToBytes(metaStealthAddress).length;
|
|
220
|
+
if (length !== identity.META_STEALTH_ADDRESS_LENGTH) {
|
|
221
|
+
throw new SubmissionFailedError(`InvalidMetaStealthAddressLength: expected ${identity.META_STEALTH_ADDRESS_LENGTH} bytes, got ${length}`);
|
|
222
|
+
}
|
|
223
|
+
const key = networkId.toLowerCase();
|
|
224
|
+
if (this.identities.has(key)) {
|
|
225
|
+
throw new SubmissionFailedError(`AlreadyRegistered: ${networkId}`);
|
|
226
|
+
}
|
|
227
|
+
this.identities.set(key, metaStealthAddress);
|
|
228
|
+
}
|
|
229
|
+
async getIdentity(networkId) {
|
|
230
|
+
return this.identities.get(networkId.toLowerCase()) ?? null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Stands in for an approved network member's server: checks the user's consent the way a real
|
|
236
|
+
* server must before writing, then writes. The Semaphore proof a real server builds before
|
|
237
|
+
* submitting on chain is implied by construction, not modeled here.
|
|
238
|
+
*/
|
|
239
|
+
class InMemoryMemberServer {
|
|
240
|
+
constructor(registry, domain) {
|
|
241
|
+
this.registry = registry;
|
|
242
|
+
this.domain = domain;
|
|
243
|
+
}
|
|
244
|
+
async submit(request) {
|
|
245
|
+
if (!verifyRegistrationRequest(request, this.domain)) {
|
|
246
|
+
throw new SubmissionFailedError("InvalidRegistrationRequest");
|
|
247
|
+
}
|
|
248
|
+
this.registry.registerIdentity(request.networkId, request.metaStealthAddress);
|
|
249
|
+
return { txHash: bytesToHex(sha3.keccak_256(hexToBytes(request.signature))) };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onboard(deviceSecret) {
|
|
254
|
+
const keys = identity.deriveKeys(deviceSecret);
|
|
255
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys);
|
|
256
|
+
return {
|
|
257
|
+
keys,
|
|
258
|
+
networkId: keys.networkId,
|
|
259
|
+
metaStealthAddress,
|
|
260
|
+
displayMetaStealthAddress: identity.metaStealthAddressUri(metaStealthAddress)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* IdentityRegistry stores the mapping as `bytes`, so its generated getter answers an unregistered
|
|
265
|
+
* networkId with empty bytes. A reader built straight onto viem or ethers therefore returns "0x"
|
|
266
|
+
* where this package's port requires null, and treating that as an entry would report success while
|
|
267
|
+
* never registering anything.
|
|
268
|
+
*/
|
|
269
|
+
function readExistingEntry(value, networkId) {
|
|
270
|
+
if (value === null || value === "0x")
|
|
271
|
+
return null;
|
|
272
|
+
const length = hexToBytes(value).length;
|
|
273
|
+
if (length !== identity.META_STEALTH_ADDRESS_LENGTH) {
|
|
274
|
+
throw new UserClientError("INVALID_INPUT", `RegistryReader returned a ${length}-byte entry for ${networkId}; expected ${identity.META_STEALTH_ADDRESS_LENGTH} bytes or null`);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
async function register(params) {
|
|
279
|
+
const { keys, networkId, metaStealthAddress } = onboard(params.deviceSecret);
|
|
280
|
+
let existingRaw;
|
|
281
|
+
try {
|
|
282
|
+
existingRaw = await params.registry.getIdentity(networkId);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
throw new RegistryReadFailedError(`registry read failed for ${networkId}`, error);
|
|
286
|
+
}
|
|
287
|
+
const existing = readExistingEntry(existingRaw, networkId);
|
|
288
|
+
if (existing !== null) {
|
|
289
|
+
// A mismatch means this device's keys do not derive the address its networkId currently
|
|
290
|
+
// resolves to. Only a network member calling updateIdentity can change that entry.
|
|
291
|
+
return {
|
|
292
|
+
status: "alreadyRegistered",
|
|
293
|
+
networkId,
|
|
294
|
+
metaStealthAddress: existing,
|
|
295
|
+
derivedMetaStealthAddress: metaStealthAddress,
|
|
296
|
+
matchesDerived: existing.toLowerCase() === metaStealthAddress.toLowerCase()
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const request = buildRegistrationRequest(keys, params.domain);
|
|
300
|
+
let receipt;
|
|
301
|
+
try {
|
|
302
|
+
receipt = await params.submitter.submit(request);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error instanceof SubmissionFailedError)
|
|
306
|
+
throw error;
|
|
307
|
+
throw new SubmissionFailedError(`Registration submission failed for ${networkId}`, error);
|
|
308
|
+
}
|
|
309
|
+
return { status: "registered", networkId, metaStealthAddress, request, receipt };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
|
|
314
|
+
* carries the registry's current entry so a caller can re-run `register()`
|
|
315
|
+
* and land in its own `alreadyRegistered` branch once that's visible to
|
|
316
|
+
* `params.registry.getIdentity`.
|
|
317
|
+
*/
|
|
318
|
+
class HttpSubmissionError extends SubmissionFailedError {
|
|
319
|
+
constructor(message, init) {
|
|
320
|
+
super(message, init.cause);
|
|
321
|
+
this.name = "HttpSubmissionError";
|
|
322
|
+
this.status = init.status;
|
|
323
|
+
this.reason = init.reason;
|
|
324
|
+
this.serverCode = init.serverCode;
|
|
325
|
+
this.metaStealthAddress = init.metaStealthAddress;
|
|
326
|
+
this.matchesRequest = init.matchesRequest;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Parses `value` as a 32-byte `0x` hex tx hash, or `null` if it isn't one. */
|
|
330
|
+
function parseTxHash(value) {
|
|
331
|
+
if (typeof value !== "string")
|
|
332
|
+
return null;
|
|
333
|
+
try {
|
|
334
|
+
return hexToBytes(value).length === TX_HASH_LENGTH ? value : null;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
|
|
342
|
+
* Never logs or throws the request body or its signature — only the response
|
|
343
|
+
* status and, where sent, the server's own error code.
|
|
344
|
+
*/
|
|
345
|
+
function createHttpRegistrationSubmitter({ url, fetch: fetchImpl = fetch }) {
|
|
346
|
+
return {
|
|
347
|
+
async submit(request) {
|
|
348
|
+
let response;
|
|
349
|
+
try {
|
|
350
|
+
response = await fetchImpl(`${url}/v1/registrations`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers: { "Content-Type": "application/json" },
|
|
353
|
+
body: JSON.stringify(request)
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
throw new HttpSubmissionError("Registration submission failed: network error", {
|
|
358
|
+
reason: "network",
|
|
359
|
+
cause: error
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
let body;
|
|
363
|
+
try {
|
|
364
|
+
body = await response.json();
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw new HttpSubmissionError(`Registration submission returned ${response.status} with no JSON body`, {
|
|
368
|
+
status: response.status,
|
|
369
|
+
reason: "invalidResponse",
|
|
370
|
+
cause: error
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
if (response.status === 201) {
|
|
374
|
+
const txHash = parseTxHash(body?.txHash);
|
|
375
|
+
if (txHash === null) {
|
|
376
|
+
throw new HttpSubmissionError("Registration submission returned 201 with a malformed body", {
|
|
377
|
+
status: 201,
|
|
378
|
+
reason: "invalidResponse"
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
return { txHash };
|
|
382
|
+
}
|
|
383
|
+
if (response.status === 200) {
|
|
384
|
+
const { metaStealthAddress, matchesRequest } = (body ?? {});
|
|
385
|
+
throw new HttpSubmissionError("Registration already exists for this networkId", {
|
|
386
|
+
status: 200,
|
|
387
|
+
reason: "alreadyRegistered",
|
|
388
|
+
metaStealthAddress,
|
|
389
|
+
matchesRequest
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (response.status === 400) {
|
|
393
|
+
throw new HttpSubmissionError("Registration submission was rejected", {
|
|
394
|
+
status: 400,
|
|
395
|
+
reason: "rejected"
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (response.status === 503) {
|
|
399
|
+
throw new HttpSubmissionError("Registration service is unavailable", {
|
|
400
|
+
status: 503,
|
|
401
|
+
reason: "unavailable",
|
|
402
|
+
serverCode: body?.code
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
throw new HttpSubmissionError(`Registration submission returned unexpected status ${response.status}`, {
|
|
406
|
+
status: response.status,
|
|
407
|
+
reason: "invalidResponse"
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
Object.defineProperty(exports, "InvalidMetaStealthAddressError", {
|
|
414
|
+
enumerable: true,
|
|
415
|
+
get: function () { return identity.InvalidMetaStealthAddressError; }
|
|
416
|
+
});
|
|
417
|
+
Object.defineProperty(exports, "displayMetaStealthAddress", {
|
|
418
|
+
enumerable: true,
|
|
419
|
+
get: function () { return identity.metaStealthAddressUri; }
|
|
420
|
+
});
|
|
421
|
+
exports.DIGEST_LENGTH = DIGEST_LENGTH;
|
|
422
|
+
exports.EIP712_DOMAIN_TYPE = EIP712_DOMAIN_TYPE;
|
|
423
|
+
exports.HttpSubmissionError = HttpSubmissionError;
|
|
424
|
+
exports.IDENTITY_REGISTRY_DOMAIN_NAME = IDENTITY_REGISTRY_DOMAIN_NAME;
|
|
425
|
+
exports.IDENTITY_REGISTRY_DOMAIN_VERSION = IDENTITY_REGISTRY_DOMAIN_VERSION;
|
|
426
|
+
exports.InMemoryIdentityRegistry = InMemoryIdentityRegistry;
|
|
427
|
+
exports.InMemoryMemberServer = InMemoryMemberServer;
|
|
428
|
+
exports.NETWORK_ID_LENGTH = NETWORK_ID_LENGTH;
|
|
429
|
+
exports.REGISTER_IDENTITY_TYPE = REGISTER_IDENTITY_TYPE;
|
|
430
|
+
exports.REQUEST_VERSION = REQUEST_VERSION;
|
|
431
|
+
exports.RegistryReadFailedError = RegistryReadFailedError;
|
|
432
|
+
exports.SCHEME_ID = SCHEME_ID;
|
|
433
|
+
exports.SIGNATURE_LENGTH = SIGNATURE_LENGTH;
|
|
434
|
+
exports.SubmissionFailedError = SubmissionFailedError;
|
|
435
|
+
exports.TX_HASH_LENGTH = TX_HASH_LENGTH;
|
|
436
|
+
exports.UserClientError = UserClientError;
|
|
437
|
+
exports.buildRegistrationRequest = buildRegistrationRequest;
|
|
438
|
+
exports.createHttpRegistrationSubmitter = createHttpRegistrationSubmitter;
|
|
439
|
+
exports.identityRegistryDomainSeparator = identityRegistryDomainSeparator;
|
|
440
|
+
exports.metaStealthAddressFromKeys = metaStealthAddressFromKeys;
|
|
441
|
+
exports.onboard = onboard;
|
|
442
|
+
exports.register = register;
|
|
443
|
+
exports.registerIdentityDigest = registerIdentityDigest;
|
|
444
|
+
exports.registerIdentityStructHash = registerIdentityStructHash;
|
|
445
|
+
exports.signRegisterIdentity = signRegisterIdentity;
|
|
446
|
+
exports.verifyRegistrationRequest = verifyRegistrationRequest;
|