@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Hex, RegistrationRequest, RegistrationSubmitter, SubmissionReceipt } from "./types"
|
|
2
|
+
import { hexToBytes } from "./bytes"
|
|
3
|
+
import { TX_HASH_LENGTH } from "./constants"
|
|
4
|
+
import { SubmissionFailedError } from "./errors"
|
|
5
|
+
|
|
6
|
+
export type HttpSubmissionReason = "alreadyRegistered" | "rejected" | "unavailable" | "network" | "invalidResponse"
|
|
7
|
+
|
|
8
|
+
export interface HttpSubmissionErrorInit {
|
|
9
|
+
status?: number
|
|
10
|
+
reason: HttpSubmissionReason
|
|
11
|
+
/** The server's own error code, where the response body carried one. */
|
|
12
|
+
serverCode?: string
|
|
13
|
+
/** The registry's current entry, on an `alreadyRegistered` response. */
|
|
14
|
+
metaStealthAddress?: Hex
|
|
15
|
+
matchesRequest?: boolean
|
|
16
|
+
cause?: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
|
|
21
|
+
* carries the registry's current entry so a caller can re-run `register()`
|
|
22
|
+
* and land in its own `alreadyRegistered` branch once that's visible to
|
|
23
|
+
* `params.registry.getIdentity`.
|
|
24
|
+
*/
|
|
25
|
+
export class HttpSubmissionError extends SubmissionFailedError {
|
|
26
|
+
readonly status?: number
|
|
27
|
+
readonly reason: HttpSubmissionReason
|
|
28
|
+
readonly serverCode?: string
|
|
29
|
+
readonly metaStealthAddress?: Hex
|
|
30
|
+
readonly matchesRequest?: boolean
|
|
31
|
+
|
|
32
|
+
constructor(message: string, init: HttpSubmissionErrorInit) {
|
|
33
|
+
super(message, init.cause)
|
|
34
|
+
this.name = "HttpSubmissionError"
|
|
35
|
+
this.status = init.status
|
|
36
|
+
this.reason = init.reason
|
|
37
|
+
this.serverCode = init.serverCode
|
|
38
|
+
this.metaStealthAddress = init.metaStealthAddress
|
|
39
|
+
this.matchesRequest = init.matchesRequest
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface HttpRegistrationSubmitterOptions {
|
|
44
|
+
url: string
|
|
45
|
+
fetch?: typeof fetch
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SubmittedBody {
|
|
49
|
+
status: "submitted"
|
|
50
|
+
txHash?: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AlreadyRegisteredBody {
|
|
54
|
+
status: "alreadyRegistered"
|
|
55
|
+
metaStealthAddress?: Hex
|
|
56
|
+
matchesRequest?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface ErrorBody {
|
|
60
|
+
code?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Parses `value` as a 32-byte `0x` hex tx hash, or `null` if it isn't one. */
|
|
64
|
+
function parseTxHash(value: unknown): Hex | null {
|
|
65
|
+
if (typeof value !== "string") return null
|
|
66
|
+
try {
|
|
67
|
+
return hexToBytes(value as Hex).length === TX_HASH_LENGTH ? (value as Hex) : null
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
|
|
75
|
+
* Never logs or throws the request body or its signature — only the response
|
|
76
|
+
* status and, where sent, the server's own error code.
|
|
77
|
+
*/
|
|
78
|
+
export function createHttpRegistrationSubmitter({
|
|
79
|
+
url,
|
|
80
|
+
fetch: fetchImpl = fetch
|
|
81
|
+
}: HttpRegistrationSubmitterOptions): RegistrationSubmitter {
|
|
82
|
+
return {
|
|
83
|
+
async submit(request: RegistrationRequest): Promise<SubmissionReceipt> {
|
|
84
|
+
let response: Response
|
|
85
|
+
try {
|
|
86
|
+
response = await fetchImpl(`${url}/v1/registrations`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify(request)
|
|
90
|
+
})
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new HttpSubmissionError("Registration submission failed: network error", {
|
|
93
|
+
reason: "network",
|
|
94
|
+
cause: error
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let body: unknown
|
|
99
|
+
try {
|
|
100
|
+
body = await response.json()
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw new HttpSubmissionError(`Registration submission returned ${response.status} with no JSON body`, {
|
|
103
|
+
status: response.status,
|
|
104
|
+
reason: "invalidResponse",
|
|
105
|
+
cause: error
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (response.status === 201) {
|
|
110
|
+
const txHash = parseTxHash((body as SubmittedBody | null)?.txHash)
|
|
111
|
+
if (txHash === null) {
|
|
112
|
+
throw new HttpSubmissionError("Registration submission returned 201 with a malformed body", {
|
|
113
|
+
status: 201,
|
|
114
|
+
reason: "invalidResponse"
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
return { txHash }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (response.status === 200) {
|
|
121
|
+
const { metaStealthAddress, matchesRequest } = (body ?? {}) as AlreadyRegisteredBody
|
|
122
|
+
throw new HttpSubmissionError("Registration already exists for this networkId", {
|
|
123
|
+
status: 200,
|
|
124
|
+
reason: "alreadyRegistered",
|
|
125
|
+
metaStealthAddress,
|
|
126
|
+
matchesRequest
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (response.status === 400) {
|
|
131
|
+
throw new HttpSubmissionError("Registration submission was rejected", {
|
|
132
|
+
status: 400,
|
|
133
|
+
reason: "rejected"
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (response.status === 503) {
|
|
138
|
+
throw new HttpSubmissionError("Registration service is unavailable", {
|
|
139
|
+
status: 503,
|
|
140
|
+
reason: "unavailable",
|
|
141
|
+
serverCode: (body as ErrorBody | null)?.code
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new HttpSubmissionError(`Registration submission returned unexpected status ${response.status}`, {
|
|
146
|
+
status: response.status,
|
|
147
|
+
reason: "invalidResponse"
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type { DerivedKeys } from "@coordination-network/identity"
|
|
2
|
+
export { InvalidMetaStealthAddressError } from "@coordination-network/identity"
|
|
3
|
+
|
|
4
|
+
export * from "./errors"
|
|
5
|
+
export * from "./types"
|
|
6
|
+
export * from "./constants"
|
|
7
|
+
export * from "./meta"
|
|
8
|
+
export * from "./eip712"
|
|
9
|
+
export * from "./request"
|
|
10
|
+
export * from "./registry"
|
|
11
|
+
export * from "./member-server"
|
|
12
|
+
export * from "./register"
|
|
13
|
+
export * from "./http-submitter"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { keccak_256 } from "@noble/hashes/sha3"
|
|
2
|
+
import { ChainDomain, RegistrationRequest, RegistrationSubmitter, SubmissionReceipt } from "./types"
|
|
3
|
+
import { hexToBytes, bytesToHex } from "./bytes"
|
|
4
|
+
import { verifyRegistrationRequest } from "./request"
|
|
5
|
+
import { SubmissionFailedError } from "./errors"
|
|
6
|
+
import { InMemoryIdentityRegistry } from "./registry"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stands in for an approved network member's server: checks the user's consent the way a real
|
|
10
|
+
* server must before writing, then writes. The Semaphore proof a real server builds before
|
|
11
|
+
* submitting on chain is implied by construction, not modeled here.
|
|
12
|
+
*/
|
|
13
|
+
export class InMemoryMemberServer implements RegistrationSubmitter {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly registry: InMemoryIdentityRegistry,
|
|
16
|
+
private readonly domain: ChainDomain
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async submit(request: RegistrationRequest): Promise<SubmissionReceipt> {
|
|
20
|
+
if (!verifyRegistrationRequest(request, this.domain)) {
|
|
21
|
+
throw new SubmissionFailedError("InvalidRegistrationRequest")
|
|
22
|
+
}
|
|
23
|
+
this.registry.registerIdentity(request.networkId, request.metaStealthAddress)
|
|
24
|
+
return { txHash: bytesToHex(keccak_256(hexToBytes(request.signature))) }
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/meta.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DerivedKeys, encodeMetaStealthAddress } from "@coordination-network/identity"
|
|
2
|
+
import { Hex } from "./types"
|
|
3
|
+
|
|
4
|
+
/** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
|
|
5
|
+
export function metaStealthAddressFromKeys(keys: DerivedKeys): Hex {
|
|
6
|
+
return encodeMetaStealthAddress({
|
|
7
|
+
spendingPublicKey: keys.spending.publicKey,
|
|
8
|
+
viewingPublicKey: keys.viewing.publicKey
|
|
9
|
+
})
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Aliased, not reimplemented, so the `st:eth:` prefix lives in one place. */
|
|
13
|
+
export { metaStealthAddressUri as displayMetaStealthAddress } from "@coordination-network/identity"
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { deriveKeys, META_STEALTH_ADDRESS_LENGTH } from "@coordination-network/identity"
|
|
2
|
+
import { Hex, Onboarding, RegisterParams, RegisterResult } from "./types"
|
|
3
|
+
import { metaStealthAddressFromKeys, displayMetaStealthAddress } from "./meta"
|
|
4
|
+
import { buildRegistrationRequest } from "./request"
|
|
5
|
+
import { hexToBytes } from "./bytes"
|
|
6
|
+
import { RegistryReadFailedError, SubmissionFailedError, UserClientError } from "./errors"
|
|
7
|
+
|
|
8
|
+
export function onboard(deviceSecret: Uint8Array | Hex): Onboarding {
|
|
9
|
+
const keys = deriveKeys(deviceSecret)
|
|
10
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys)
|
|
11
|
+
return {
|
|
12
|
+
keys,
|
|
13
|
+
networkId: keys.networkId,
|
|
14
|
+
metaStealthAddress,
|
|
15
|
+
displayMetaStealthAddress: displayMetaStealthAddress(metaStealthAddress)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* IdentityRegistry stores the mapping as `bytes`, so its generated getter answers an unregistered
|
|
21
|
+
* networkId with empty bytes. A reader built straight onto viem or ethers therefore returns "0x"
|
|
22
|
+
* where this package's port requires null, and treating that as an entry would report success while
|
|
23
|
+
* never registering anything.
|
|
24
|
+
*/
|
|
25
|
+
function readExistingEntry(value: Hex | null, networkId: Hex): Hex | null {
|
|
26
|
+
if (value === null || value === "0x") return null
|
|
27
|
+
|
|
28
|
+
const length = hexToBytes(value).length
|
|
29
|
+
if (length !== META_STEALTH_ADDRESS_LENGTH) {
|
|
30
|
+
throw new UserClientError(
|
|
31
|
+
"INVALID_INPUT",
|
|
32
|
+
`RegistryReader returned a ${length}-byte entry for ${networkId}; expected ${META_STEALTH_ADDRESS_LENGTH} bytes or null`
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function register(params: RegisterParams): Promise<RegisterResult> {
|
|
39
|
+
const { keys, networkId, metaStealthAddress } = onboard(params.deviceSecret)
|
|
40
|
+
|
|
41
|
+
let existingRaw: Hex | null
|
|
42
|
+
try {
|
|
43
|
+
existingRaw = await params.registry.getIdentity(networkId)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new RegistryReadFailedError(`registry read failed for ${networkId}`, error)
|
|
46
|
+
}
|
|
47
|
+
const existing = readExistingEntry(existingRaw, networkId)
|
|
48
|
+
if (existing !== null) {
|
|
49
|
+
// A mismatch means this device's keys do not derive the address its networkId currently
|
|
50
|
+
// resolves to. Only a network member calling updateIdentity can change that entry.
|
|
51
|
+
return {
|
|
52
|
+
status: "alreadyRegistered",
|
|
53
|
+
networkId,
|
|
54
|
+
metaStealthAddress: existing,
|
|
55
|
+
derivedMetaStealthAddress: metaStealthAddress,
|
|
56
|
+
matchesDerived: existing.toLowerCase() === metaStealthAddress.toLowerCase()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const request = buildRegistrationRequest(keys, params.domain)
|
|
61
|
+
let receipt
|
|
62
|
+
try {
|
|
63
|
+
receipt = await params.submitter.submit(request)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof SubmissionFailedError) throw error
|
|
66
|
+
throw new SubmissionFailedError(`Registration submission failed for ${networkId}`, error)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { status: "registered", networkId, metaStealthAddress, request, receipt }
|
|
70
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { META_STEALTH_ADDRESS_LENGTH } from "@coordination-network/identity"
|
|
2
|
+
import { Hex, RegistryReader } from "./types"
|
|
3
|
+
import { hexToBytes } from "./bytes"
|
|
4
|
+
import { SubmissionFailedError } from "./errors"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-memory stand-in for the registry's read model and the two reverts on its register path,
|
|
8
|
+
* length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
|
|
9
|
+
* asserts both reverts against the real contract and this fake side by side.
|
|
10
|
+
*/
|
|
11
|
+
export class InMemoryIdentityRegistry implements RegistryReader {
|
|
12
|
+
private readonly identities = new Map<Hex, Hex>()
|
|
13
|
+
|
|
14
|
+
registerIdentity(networkId: Hex, metaStealthAddress: Hex): void {
|
|
15
|
+
const length = hexToBytes(metaStealthAddress).length
|
|
16
|
+
if (length !== META_STEALTH_ADDRESS_LENGTH) {
|
|
17
|
+
throw new SubmissionFailedError(
|
|
18
|
+
`InvalidMetaStealthAddressLength: expected ${META_STEALTH_ADDRESS_LENGTH} bytes, got ${length}`
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const key = networkId.toLowerCase() as Hex
|
|
23
|
+
if (this.identities.has(key)) {
|
|
24
|
+
throw new SubmissionFailedError(`AlreadyRegistered: ${networkId}`)
|
|
25
|
+
}
|
|
26
|
+
this.identities.set(key, metaStealthAddress)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getIdentity(networkId: Hex): Promise<Hex | null> {
|
|
30
|
+
return this.identities.get(networkId.toLowerCase() as Hex) ?? null
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { secp256k1 } from "@noble/curves/secp256k1"
|
|
2
|
+
import {
|
|
3
|
+
computeNetworkId,
|
|
4
|
+
DerivedKeys,
|
|
5
|
+
META_STEALTH_ADDRESS_LENGTH,
|
|
6
|
+
validateMetaStealthAddress
|
|
7
|
+
} from "@coordination-network/identity"
|
|
8
|
+
import { ChainDomain, Hex, RegistrationRequest } from "./types"
|
|
9
|
+
import { hexToBytes, bytesToHex, concatBytes } from "./bytes"
|
|
10
|
+
import { UserClientError } from "./errors"
|
|
11
|
+
import { registerIdentityDigest } from "./eip712"
|
|
12
|
+
import { metaStealthAddressFromKeys } from "./meta"
|
|
13
|
+
import { DIGEST_LENGTH, NETWORK_ID_LENGTH, REQUEST_VERSION, SCHEME_ID, SIGNATURE_LENGTH } from "./constants"
|
|
14
|
+
|
|
15
|
+
/** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
|
|
16
|
+
export function signRegisterIdentity(accountPrivateKey: Hex, digest: Hex): Hex {
|
|
17
|
+
const digestBytes = hexToBytes(digest)
|
|
18
|
+
if (digestBytes.length !== DIGEST_LENGTH) {
|
|
19
|
+
// Exported, so refuse to sign anything that is not a digest this package produced.
|
|
20
|
+
throw new UserClientError("INVALID_INPUT", `Expected a ${DIGEST_LENGTH}-byte digest, got ${digestBytes.length}`)
|
|
21
|
+
}
|
|
22
|
+
const signature = secp256k1.sign(digestBytes, hexToBytes(accountPrivateKey), { lowS: true })
|
|
23
|
+
return bytesToHex(concatBytes(signature.toCompactRawBytes(), Uint8Array.from([27 + signature.recovery])))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildRegistrationRequest(keys: DerivedKeys, domain: ChainDomain): RegistrationRequest {
|
|
27
|
+
const metaStealthAddress = metaStealthAddressFromKeys(keys)
|
|
28
|
+
const digest = registerIdentityDigest(domain, keys.networkId, metaStealthAddress)
|
|
29
|
+
return {
|
|
30
|
+
version: REQUEST_VERSION,
|
|
31
|
+
networkId: keys.networkId,
|
|
32
|
+
schemeId: SCHEME_ID,
|
|
33
|
+
metaStealthAddress,
|
|
34
|
+
chainId: domain.chainId,
|
|
35
|
+
verifyingContract: domain.verifyingContract,
|
|
36
|
+
signature: signRegisterIdentity(keys.account.privateKey, digest)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a member server pinned to `expected` should accept the request: field widths, a
|
|
42
|
+
* well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
|
|
43
|
+
*
|
|
44
|
+
* `expected` is required because the domain inside a request is attacker-controlled — verifying
|
|
45
|
+
* against it would only prove the request is self-consistent, not that it targets this registry.
|
|
46
|
+
*
|
|
47
|
+
* Returns false rather than throwing, so an untrusted request can be passed in as-is.
|
|
48
|
+
*/
|
|
49
|
+
export function verifyRegistrationRequest(request: RegistrationRequest, expected: ChainDomain): boolean {
|
|
50
|
+
try {
|
|
51
|
+
if (request.version !== REQUEST_VERSION || request.schemeId !== SCHEME_ID) return false
|
|
52
|
+
|
|
53
|
+
// Neither field is covered by the signature, so both have to be pinned to the caller's own
|
|
54
|
+
// registry. Otherwise a signature made for another chain or contract replays here.
|
|
55
|
+
if (request.chainId !== expected.chainId) return false
|
|
56
|
+
if (request.verifyingContract.toLowerCase() !== expected.verifyingContract.toLowerCase()) return false
|
|
57
|
+
|
|
58
|
+
// The contract types these as bytes32 and a 66-byte bytes. A request signed self-consistently
|
|
59
|
+
// over the wrong width verifies against itself but cannot be submitted.
|
|
60
|
+
const networkIdBytes = hexToBytes(request.networkId)
|
|
61
|
+
if (networkIdBytes.length !== NETWORK_ID_LENGTH) return false
|
|
62
|
+
if (hexToBytes(request.metaStealthAddress).length !== META_STEALTH_ADDRESS_LENGTH) return false
|
|
63
|
+
|
|
64
|
+
// Width alone admits 66 bytes the contract would store but no one could ever pay to.
|
|
65
|
+
if (!validateMetaStealthAddress(request.metaStealthAddress)) return false
|
|
66
|
+
|
|
67
|
+
const signatureBytes = hexToBytes(request.signature)
|
|
68
|
+
if (signatureBytes.length !== SIGNATURE_LENGTH) return false
|
|
69
|
+
|
|
70
|
+
const rs = signatureBytes.slice(0, 64)
|
|
71
|
+
const recovery = signatureBytes[64] - 27
|
|
72
|
+
if (recovery !== 0 && recovery !== 1) return false
|
|
73
|
+
|
|
74
|
+
const digest = registerIdentityDigest(expected, request.networkId, request.metaStealthAddress)
|
|
75
|
+
const signature = secp256k1.Signature.fromCompact(rs).addRecoveryBit(recovery)
|
|
76
|
+
|
|
77
|
+
// Signing uses `lowS` (EIP-2), so verification rejects high-S to agree with it.
|
|
78
|
+
if (signature.hasHighS()) return false
|
|
79
|
+
|
|
80
|
+
const recoveredPublicKey = signature.recoverPublicKey(hexToBytes(digest)).toBytes(false)
|
|
81
|
+
|
|
82
|
+
// computeNetworkId is keccak256(X || Y); compare all 32 bytes. An address-style check over the
|
|
83
|
+
// low 20 would leave the top 12 unauthenticated and let one key claim 2^96 ids. The contract
|
|
84
|
+
// itself checks no signer at all.
|
|
85
|
+
return computeNetworkId(recoveredPublicKey).toLowerCase() === request.networkId.toLowerCase()
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { DerivedKeys } from "@coordination-network/identity"
|
|
2
|
+
|
|
3
|
+
export type Hex = `0x${string}`
|
|
4
|
+
|
|
5
|
+
/** The registry deployment the consent is bound to. */
|
|
6
|
+
export interface ChainDomain {
|
|
7
|
+
chainId: number
|
|
8
|
+
verifyingContract: Hex
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RegistrationRequest {
|
|
12
|
+
version: "v1"
|
|
13
|
+
networkId: Hex
|
|
14
|
+
schemeId: 1
|
|
15
|
+
metaStealthAddress: Hex
|
|
16
|
+
chainId: number
|
|
17
|
+
verifyingContract: Hex
|
|
18
|
+
/** EIP-712 consent over the request, checked by the member server. */
|
|
19
|
+
signature: Hex
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SubmissionReceipt {
|
|
23
|
+
/** Null when the submitter queued the registration rather than broadcasting it itself. */
|
|
24
|
+
txHash: Hex | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RegistryReader {
|
|
28
|
+
/**
|
|
29
|
+
* The 66-byte entry, or null when the networkId is unregistered. IdentityRegistry stores the
|
|
30
|
+
* mapping as `bytes`, so a chain-backed reader must map its empty-bytes answer to null.
|
|
31
|
+
*/
|
|
32
|
+
getIdentity(networkId: Hex): Promise<Hex | null>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Hands the signed request to the network member's server, which submits it on chain. */
|
|
36
|
+
export interface RegistrationSubmitter {
|
|
37
|
+
submit(request: RegistrationRequest): Promise<SubmissionReceipt>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Onboarding {
|
|
41
|
+
keys: DerivedKeys
|
|
42
|
+
networkId: Hex
|
|
43
|
+
metaStealthAddress: Hex
|
|
44
|
+
displayMetaStealthAddress: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RegisterParams {
|
|
48
|
+
deviceSecret: Uint8Array | Hex
|
|
49
|
+
registry: RegistryReader
|
|
50
|
+
submitter: RegistrationSubmitter
|
|
51
|
+
domain: ChainDomain
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RegisterResult =
|
|
55
|
+
| {
|
|
56
|
+
/** Submitted, not confirmed: `register` returns as soon as the submitter accepts. */
|
|
57
|
+
status: "registered"
|
|
58
|
+
networkId: Hex
|
|
59
|
+
metaStealthAddress: Hex
|
|
60
|
+
request: RegistrationRequest
|
|
61
|
+
receipt: SubmissionReceipt
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
status: "alreadyRegistered"
|
|
65
|
+
networkId: Hex
|
|
66
|
+
/** What the registry currently resolves to; a network member can change it via updateIdentity/removeIdentity. */
|
|
67
|
+
metaStealthAddress: Hex
|
|
68
|
+
/** What this device secret derives today. */
|
|
69
|
+
derivedMetaStealthAddress: Hex
|
|
70
|
+
matchesDerived: boolean
|
|
71
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { DerivedKeys } from '@coordination-network/identity';
|
|
2
|
+
export { DerivedKeys, InvalidMetaStealthAddressError, metaStealthAddressUri as displayMetaStealthAddress } from '@coordination-network/identity';
|
|
3
|
+
|
|
4
|
+
type UserClientErrorCode = "INVALID_INPUT" | "SUBMISSION_FAILED" | "REGISTRY_READ_FAILED";
|
|
5
|
+
/** Base error class for all errors thrown by this package, discriminated by `code`. */
|
|
6
|
+
declare class UserClientError extends Error {
|
|
7
|
+
readonly code: UserClientErrorCode;
|
|
8
|
+
constructor(code: UserClientErrorCode, message: string);
|
|
9
|
+
}
|
|
10
|
+
declare class SubmissionFailedError extends UserClientError {
|
|
11
|
+
readonly cause?: unknown;
|
|
12
|
+
constructor(message: string, cause?: unknown);
|
|
13
|
+
}
|
|
14
|
+
declare class RegistryReadFailedError extends UserClientError {
|
|
15
|
+
readonly cause?: unknown;
|
|
16
|
+
constructor(message: string, cause?: unknown);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Hex = `0x${string}`;
|
|
20
|
+
/** The registry deployment the consent is bound to. */
|
|
21
|
+
interface ChainDomain {
|
|
22
|
+
chainId: number;
|
|
23
|
+
verifyingContract: Hex;
|
|
24
|
+
}
|
|
25
|
+
interface RegistrationRequest {
|
|
26
|
+
version: "v1";
|
|
27
|
+
networkId: Hex;
|
|
28
|
+
schemeId: 1;
|
|
29
|
+
metaStealthAddress: Hex;
|
|
30
|
+
chainId: number;
|
|
31
|
+
verifyingContract: Hex;
|
|
32
|
+
/** EIP-712 consent over the request, checked by the member server. */
|
|
33
|
+
signature: Hex;
|
|
34
|
+
}
|
|
35
|
+
interface SubmissionReceipt {
|
|
36
|
+
/** Null when the submitter queued the registration rather than broadcasting it itself. */
|
|
37
|
+
txHash: Hex | null;
|
|
38
|
+
}
|
|
39
|
+
interface RegistryReader {
|
|
40
|
+
/**
|
|
41
|
+
* The 66-byte entry, or null when the networkId is unregistered. IdentityRegistry stores the
|
|
42
|
+
* mapping as `bytes`, so a chain-backed reader must map its empty-bytes answer to null.
|
|
43
|
+
*/
|
|
44
|
+
getIdentity(networkId: Hex): Promise<Hex | null>;
|
|
45
|
+
}
|
|
46
|
+
/** Hands the signed request to the network member's server, which submits it on chain. */
|
|
47
|
+
interface RegistrationSubmitter {
|
|
48
|
+
submit(request: RegistrationRequest): Promise<SubmissionReceipt>;
|
|
49
|
+
}
|
|
50
|
+
interface Onboarding {
|
|
51
|
+
keys: DerivedKeys;
|
|
52
|
+
networkId: Hex;
|
|
53
|
+
metaStealthAddress: Hex;
|
|
54
|
+
displayMetaStealthAddress: string;
|
|
55
|
+
}
|
|
56
|
+
interface RegisterParams {
|
|
57
|
+
deviceSecret: Uint8Array | Hex;
|
|
58
|
+
registry: RegistryReader;
|
|
59
|
+
submitter: RegistrationSubmitter;
|
|
60
|
+
domain: ChainDomain;
|
|
61
|
+
}
|
|
62
|
+
type RegisterResult = {
|
|
63
|
+
/** Submitted, not confirmed: `register` returns as soon as the submitter accepts. */
|
|
64
|
+
status: "registered";
|
|
65
|
+
networkId: Hex;
|
|
66
|
+
metaStealthAddress: Hex;
|
|
67
|
+
request: RegistrationRequest;
|
|
68
|
+
receipt: SubmissionReceipt;
|
|
69
|
+
} | {
|
|
70
|
+
status: "alreadyRegistered";
|
|
71
|
+
networkId: Hex;
|
|
72
|
+
/** What the registry currently resolves to; a network member can change it via updateIdentity/removeIdentity. */
|
|
73
|
+
metaStealthAddress: Hex;
|
|
74
|
+
/** What this device secret derives today. */
|
|
75
|
+
derivedMetaStealthAddress: Hex;
|
|
76
|
+
matchesDerived: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** ERC-6538 scheme 1 (secp256k1 stealth addresses), the schemeId field of the consent struct. */
|
|
80
|
+
declare const SCHEME_ID: 1;
|
|
81
|
+
/** networkId is a bytes32 on chain, and abi.encode splices it in as one word. */
|
|
82
|
+
declare const NETWORK_ID_LENGTH = 32;
|
|
83
|
+
/** r || s || v, the 65-byte Ethereum signature layout. */
|
|
84
|
+
declare const SIGNATURE_LENGTH = 65;
|
|
85
|
+
/** keccak256 output, the only thing this package will sign. */
|
|
86
|
+
declare const DIGEST_LENGTH = 32;
|
|
87
|
+
/** Ethereum transaction hash, as HttpSubmissionError's 201 branch expects it. */
|
|
88
|
+
declare const TX_HASH_LENGTH = 32;
|
|
89
|
+
/** Wire-format version of RegistrationRequest. */
|
|
90
|
+
declare const REQUEST_VERSION: "v1";
|
|
91
|
+
declare const IDENTITY_REGISTRY_DOMAIN_NAME = "IdentityRegistry";
|
|
92
|
+
declare const IDENTITY_REGISTRY_DOMAIN_VERSION = "1";
|
|
93
|
+
/** Signed by the user as consent to register; the member server verifies it, the contract never reads it. */
|
|
94
|
+
declare const REGISTER_IDENTITY_TYPE = "RegisterIdentity(bytes32 networkId,uint256 schemeId,bytes metaStealthAddress)";
|
|
95
|
+
declare const EIP712_DOMAIN_TYPE = "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)";
|
|
96
|
+
|
|
97
|
+
/** Encodes a device's spending and viewing keys as its ERC-6538 scheme-1 meta-stealth address. */
|
|
98
|
+
declare function metaStealthAddressFromKeys(keys: DerivedKeys): Hex;
|
|
99
|
+
|
|
100
|
+
declare function identityRegistryDomainSeparator(domain: ChainDomain): Hex;
|
|
101
|
+
declare function registerIdentityStructHash(networkId: Hex, metaStealthAddress: Hex): Hex;
|
|
102
|
+
/**
|
|
103
|
+
* The digest the user signs as consent to register this meta-stealth address at this registry on
|
|
104
|
+
* this chain. The member server verifies it before submitting; the contract never reads it.
|
|
105
|
+
*/
|
|
106
|
+
declare function registerIdentityDigest(domain: ChainDomain, networkId: Hex, metaStealthAddress: Hex): Hex;
|
|
107
|
+
|
|
108
|
+
/** Returns a 65-byte `r || s || v` signature, with `v = 27 + recovery id` as the contract expects. */
|
|
109
|
+
declare function signRegisterIdentity(accountPrivateKey: Hex, digest: Hex): Hex;
|
|
110
|
+
declare function buildRegistrationRequest(keys: DerivedKeys, domain: ChainDomain): RegistrationRequest;
|
|
111
|
+
/**
|
|
112
|
+
* Whether a member server pinned to `expected` should accept the request: field widths, a
|
|
113
|
+
* well-formed meta-stealth address, the domain, and a signer matching the full `networkId`.
|
|
114
|
+
*
|
|
115
|
+
* `expected` is required because the domain inside a request is attacker-controlled — verifying
|
|
116
|
+
* against it would only prove the request is self-consistent, not that it targets this registry.
|
|
117
|
+
*
|
|
118
|
+
* Returns false rather than throwing, so an untrusted request can be passed in as-is.
|
|
119
|
+
*/
|
|
120
|
+
declare function verifyRegistrationRequest(request: RegistrationRequest, expected: ChainDomain): boolean;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* In-memory stand-in for the registry's read model and the two reverts on its register path,
|
|
124
|
+
* length then duplicate; updateIdentity/removeIdentity are not modelled. `tests/integration`
|
|
125
|
+
* asserts both reverts against the real contract and this fake side by side.
|
|
126
|
+
*/
|
|
127
|
+
declare class InMemoryIdentityRegistry implements RegistryReader {
|
|
128
|
+
private readonly identities;
|
|
129
|
+
registerIdentity(networkId: Hex, metaStealthAddress: Hex): void;
|
|
130
|
+
getIdentity(networkId: Hex): Promise<Hex | null>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Stands in for an approved network member's server: checks the user's consent the way a real
|
|
135
|
+
* server must before writing, then writes. The Semaphore proof a real server builds before
|
|
136
|
+
* submitting on chain is implied by construction, not modeled here.
|
|
137
|
+
*/
|
|
138
|
+
declare class InMemoryMemberServer implements RegistrationSubmitter {
|
|
139
|
+
private readonly registry;
|
|
140
|
+
private readonly domain;
|
|
141
|
+
constructor(registry: InMemoryIdentityRegistry, domain: ChainDomain);
|
|
142
|
+
submit(request: RegistrationRequest): Promise<SubmissionReceipt>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
declare function onboard(deviceSecret: Uint8Array | Hex): Onboarding;
|
|
146
|
+
declare function register(params: RegisterParams): Promise<RegisterResult>;
|
|
147
|
+
|
|
148
|
+
type HttpSubmissionReason = "alreadyRegistered" | "rejected" | "unavailable" | "network" | "invalidResponse";
|
|
149
|
+
interface HttpSubmissionErrorInit {
|
|
150
|
+
status?: number;
|
|
151
|
+
reason: HttpSubmissionReason;
|
|
152
|
+
/** The server's own error code, where the response body carried one. */
|
|
153
|
+
serverCode?: string;
|
|
154
|
+
/** The registry's current entry, on an `alreadyRegistered` response. */
|
|
155
|
+
metaStealthAddress?: Hex;
|
|
156
|
+
matchesRequest?: boolean;
|
|
157
|
+
cause?: unknown;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Thrown by {@link createHttpRegistrationSubmitter}. `alreadyRegistered`
|
|
161
|
+
* carries the registry's current entry so a caller can re-run `register()`
|
|
162
|
+
* and land in its own `alreadyRegistered` branch once that's visible to
|
|
163
|
+
* `params.registry.getIdentity`.
|
|
164
|
+
*/
|
|
165
|
+
declare class HttpSubmissionError extends SubmissionFailedError {
|
|
166
|
+
readonly status?: number;
|
|
167
|
+
readonly reason: HttpSubmissionReason;
|
|
168
|
+
readonly serverCode?: string;
|
|
169
|
+
readonly metaStealthAddress?: Hex;
|
|
170
|
+
readonly matchesRequest?: boolean;
|
|
171
|
+
constructor(message: string, init: HttpSubmissionErrorInit);
|
|
172
|
+
}
|
|
173
|
+
interface HttpRegistrationSubmitterOptions {
|
|
174
|
+
url: string;
|
|
175
|
+
fetch?: typeof fetch;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* HTTP `RegistrationSubmitter` for `apps/issuer-service`'s `POST /v1/registrations`.
|
|
179
|
+
* Never logs or throws the request body or its signature — only the response
|
|
180
|
+
* status and, where sent, the server's own error code.
|
|
181
|
+
*/
|
|
182
|
+
declare function createHttpRegistrationSubmitter({ url, fetch: fetchImpl }: HttpRegistrationSubmitterOptions): RegistrationSubmitter;
|
|
183
|
+
|
|
184
|
+
export { DIGEST_LENGTH, EIP712_DOMAIN_TYPE, HttpSubmissionError, IDENTITY_REGISTRY_DOMAIN_NAME, IDENTITY_REGISTRY_DOMAIN_VERSION, InMemoryIdentityRegistry, InMemoryMemberServer, NETWORK_ID_LENGTH, REGISTER_IDENTITY_TYPE, REQUEST_VERSION, RegistryReadFailedError, SCHEME_ID, SIGNATURE_LENGTH, SubmissionFailedError, TX_HASH_LENGTH, UserClientError, buildRegistrationRequest, createHttpRegistrationSubmitter, identityRegistryDomainSeparator, metaStealthAddressFromKeys, onboard, register, registerIdentityDigest, registerIdentityStructHash, signRegisterIdentity, verifyRegistrationRequest };
|
|
185
|
+
export type { ChainDomain, Hex, HttpRegistrationSubmitterOptions, HttpSubmissionErrorInit, HttpSubmissionReason, Onboarding, RegisterParams, RegisterResult, RegistrationRequest, RegistrationSubmitter, RegistryReader, SubmissionReceipt, UserClientErrorCode };
|