zklicensing 0.1.0-beta.2 → 0.1.0-beta.21
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/README.md +417 -201
- package/build/agent.d.ts +371 -0
- package/build/agent.d.ts.map +1 -0
- package/build/agent.js +553 -0
- package/build/agent.js.map +1 -0
- package/build/agentActions.d.ts +841 -0
- package/build/agentActions.d.ts.map +1 -0
- package/build/agentActions.js +581 -0
- package/build/agentActions.js.map +1 -0
- package/build/agentRecord.d.ts +83 -0
- package/build/agentRecord.d.ts.map +1 -0
- package/build/agentRecord.js +69 -0
- package/build/agentRecord.js.map +1 -0
- package/build/agentVendor.d.ts +220 -0
- package/build/agentVendor.d.ts.map +1 -0
- package/build/agentVendor.js +306 -0
- package/build/agentVendor.js.map +1 -0
- package/build/appsLookup.d.ts +14 -0
- package/build/appsLookup.d.ts.map +1 -0
- package/build/appsLookup.js +12 -0
- package/build/appsLookup.js.map +1 -0
- package/build/archiveBootstrap.d.ts +31 -5
- package/build/archiveBootstrap.d.ts.map +1 -0
- package/build/archiveBootstrap.js +140 -47
- package/build/archiveBootstrap.js.map +1 -0
- package/build/atomicJson.d.ts +5 -0
- package/build/atomicJson.d.ts.map +1 -0
- package/build/atomicJson.js +55 -0
- package/build/atomicJson.js.map +1 -0
- package/build/bindClient.d.ts +85 -0
- package/build/bindClient.d.ts.map +1 -0
- package/build/bindClient.js +187 -0
- package/build/bindClient.js.map +1 -0
- package/build/buyClient.d.ts +27 -4
- package/build/buyClient.d.ts.map +1 -0
- package/build/buyClient.js +72 -26
- package/build/buyClient.js.map +1 -0
- package/build/chainHead.d.ts +61 -0
- package/build/chainHead.d.ts.map +1 -0
- package/build/chainHead.js +88 -0
- package/build/chainHead.js.map +1 -0
- package/build/compactMerkleMap.d.ts +72 -0
- package/build/compactMerkleMap.d.ts.map +1 -0
- package/build/compactMerkleMap.js +224 -0
- package/build/compactMerkleMap.js.map +1 -0
- package/build/contractInterface.d.ts +191 -51
- package/build/contractInterface.d.ts.map +1 -0
- package/build/contractInterface.js +234 -43
- package/build/contractInterface.js.map +1 -0
- package/build/contractTypes.d.ts +9 -0
- package/build/contractTypes.d.ts.map +1 -0
- package/build/contractTypes.js +28 -0
- package/build/contractTypes.js.map +1 -0
- package/build/countryStats.d.ts +12 -3
- package/build/countryStats.d.ts.map +1 -0
- package/build/countryStats.js +87 -23
- package/build/countryStats.js.map +1 -0
- package/build/deployClient.d.ts +138 -24
- package/build/deployClient.d.ts.map +1 -0
- package/build/deployClient.js +91 -31
- package/build/deployClient.js.map +1 -0
- package/build/deviceIdentity.d.ts +68 -0
- package/build/deviceIdentity.d.ts.map +1 -0
- package/build/deviceIdentity.js +209 -0
- package/build/deviceIdentity.js.map +1 -0
- package/build/freezeClient.d.ts +26 -6
- package/build/freezeClient.d.ts.map +1 -0
- package/build/freezeClient.js +30 -6
- package/build/freezeClient.js.map +1 -0
- package/build/generationClient.d.ts +6 -3
- package/build/generationClient.d.ts.map +1 -0
- package/build/generationClient.js +25 -38
- package/build/generationClient.js.map +1 -0
- package/build/holderSecret.d.ts +70 -0
- package/build/holderSecret.d.ts.map +1 -0
- package/build/holderSecret.js +76 -0
- package/build/holderSecret.js.map +1 -0
- package/build/index.d.ts +38 -38
- package/build/index.d.ts.map +1 -0
- package/build/index.js +97 -121
- package/build/index.js.map +1 -0
- package/build/keysClient.d.ts +142 -0
- package/build/keysClient.d.ts.map +1 -0
- package/build/keysClient.js +109 -0
- package/build/keysClient.js.map +1 -0
- package/build/licenseLeaf.d.ts +76 -0
- package/build/licenseLeaf.d.ts.map +1 -0
- package/build/licenseLeaf.js +189 -0
- package/build/licenseLeaf.js.map +1 -0
- package/build/licenseRecord.d.ts +68 -5
- package/build/licenseRecord.d.ts.map +1 -0
- package/build/licenseRecord.js +26 -1
- package/build/licenseRecord.js.map +1 -0
- package/build/licenseStore.d.ts +81 -4
- package/build/licenseStore.d.ts.map +1 -0
- package/build/licenseStore.js +400 -30
- package/build/licenseStore.js.map +1 -0
- package/build/licenseTrustless.d.ts +43 -0
- package/build/licenseTrustless.d.ts.map +1 -0
- package/build/licenseTrustless.js +180 -0
- package/build/licenseTrustless.js.map +1 -0
- package/build/manifestVerify.d.ts +29 -1
- package/build/manifestVerify.d.ts.map +1 -0
- package/build/manifestVerify.js +46 -4
- package/build/manifestVerify.js.map +1 -0
- package/build/minaPriceHistory.d.ts +9 -0
- package/build/minaPriceHistory.d.ts.map +1 -0
- package/build/minaPriceHistory.js +152 -0
- package/build/minaPriceHistory.js.map +1 -0
- package/build/onChainRoot.d.ts +60 -0
- package/build/onChainRoot.d.ts.map +1 -0
- package/build/onChainRoot.js +74 -0
- package/build/onChainRoot.js.map +1 -0
- package/build/ownershipLicenseHash.d.ts +25 -0
- package/build/ownershipLicenseHash.d.ts.map +1 -0
- package/build/ownershipLicenseHash.js +29 -0
- package/build/ownershipLicenseHash.js.map +1 -0
- package/build/ownershipSignature.d.ts +153 -0
- package/build/ownershipSignature.d.ts.map +1 -0
- package/build/ownershipSignature.js +297 -0
- package/build/ownershipSignature.js.map +1 -0
- package/build/permitCheckFlow.d.ts +155 -0
- package/build/permitCheckFlow.d.ts.map +1 -0
- package/build/permitCheckFlow.js +196 -0
- package/build/permitCheckFlow.js.map +1 -0
- package/build/permitDescribe.d.ts +124 -0
- package/build/permitDescribe.d.ts.map +1 -0
- package/build/permitDescribe.js +324 -0
- package/build/permitDescribe.js.map +1 -0
- package/build/permitInterface.d.ts +85 -0
- package/build/permitInterface.d.ts.map +1 -0
- package/build/permitInterface.js +121 -0
- package/build/permitInterface.js.map +1 -0
- package/build/permitMessages.d.ts +313 -0
- package/build/permitMessages.d.ts.map +1 -0
- package/build/permitMessages.js +462 -0
- package/build/permitMessages.js.map +1 -0
- package/build/permitProof.d.ts +108 -0
- package/build/permitProof.d.ts.map +1 -0
- package/build/permitProof.js +202 -0
- package/build/permitProof.js.map +1 -0
- package/build/permitRegistryRebuild.d.ts +166 -0
- package/build/permitRegistryRebuild.d.ts.map +1 -0
- package/build/permitRegistryRebuild.js +347 -0
- package/build/permitRegistryRebuild.js.map +1 -0
- package/build/permitTree.d.ts +88 -0
- package/build/permitTree.d.ts.map +1 -0
- package/build/permitTree.js +230 -0
- package/build/permitTree.js.map +1 -0
- package/build/permitsClient.d.ts +680 -0
- package/build/permitsClient.d.ts.map +1 -0
- package/build/permitsClient.js +597 -0
- package/build/permitsClient.js.map +1 -0
- package/build/proveErrors.d.ts +50 -0
- package/build/proveErrors.d.ts.map +1 -0
- package/build/proveErrors.js +76 -0
- package/build/proveErrors.js.map +1 -0
- package/build/receipt.d.ts +95 -0
- package/build/receipt.d.ts.map +1 -0
- package/build/receipt.js +84 -0
- package/build/receipt.js.map +1 -0
- package/build/refundClient.d.ts +7 -12
- package/build/refundClient.d.ts.map +1 -0
- package/build/refundClient.js +52 -21
- package/build/refundClient.js.map +1 -0
- package/build/registryTrustVerify.d.ts +78 -0
- package/build/registryTrustVerify.d.ts.map +1 -0
- package/build/registryTrustVerify.js +129 -0
- package/build/registryTrustVerify.js.map +1 -0
- package/build/releaseClient.d.ts +26 -0
- package/build/releaseClient.d.ts.map +1 -0
- package/build/releaseClient.js +65 -0
- package/build/releaseClient.js.map +1 -0
- package/build/renewClient.d.ts +28 -2
- package/build/renewClient.d.ts.map +1 -0
- package/build/renewClient.js +31 -27
- package/build/renewClient.js.map +1 -0
- package/build/seatName.d.ts +8 -0
- package/build/seatName.d.ts.map +1 -0
- package/build/seatName.js +136 -0
- package/build/seatName.js.map +1 -0
- package/build/slotConstants.d.ts +15 -0
- package/build/slotConstants.d.ts.map +1 -0
- package/build/slotConstants.js +17 -0
- package/build/slotConstants.js.map +1 -0
- package/build/statsClient.d.ts +61 -7
- package/build/statsClient.d.ts.map +1 -0
- package/build/statsClient.js +44 -5
- package/build/statsClient.js.map +1 -0
- package/build/successorChain.d.ts +35 -0
- package/build/successorChain.d.ts.map +1 -0
- package/build/successorChain.js +69 -0
- package/build/successorChain.js.map +1 -0
- package/build/taxId.d.ts +44 -0
- package/build/taxId.d.ts.map +1 -0
- package/build/taxId.js +212 -0
- package/build/taxId.js.map +1 -0
- package/build/trustAnchorVerify.d.ts +49 -0
- package/build/trustAnchorVerify.d.ts.map +1 -0
- package/build/trustAnchorVerify.js +432 -0
- package/build/trustAnchorVerify.js.map +1 -0
- package/build/trustAnchorVerifyCli.d.ts +45 -0
- package/build/trustAnchorVerifyCli.d.ts.map +1 -0
- package/build/trustAnchorVerifyCli.js +235 -0
- package/build/trustAnchorVerifyCli.js.map +1 -0
- package/build/txBroadcastClient.d.ts +26 -0
- package/build/txBroadcastClient.d.ts.map +1 -0
- package/build/txBroadcastClient.js +20 -0
- package/build/txBroadcastClient.js.map +1 -0
- package/build/unbindClient.d.ts +77 -0
- package/build/unbindClient.d.ts.map +1 -0
- package/build/unbindClient.js +169 -0
- package/build/unbindClient.js.map +1 -0
- package/build/units.d.ts +28 -0
- package/build/units.d.ts.map +1 -0
- package/build/units.js +65 -0
- package/build/units.js.map +1 -0
- package/build/vatRates.d.ts +54 -0
- package/build/vatRates.d.ts.map +1 -0
- package/build/vatRates.js +82 -0
- package/build/vatRates.js.map +1 -0
- package/build/vatRatesData.d.ts +5 -0
- package/build/vatRatesData.d.ts.map +1 -0
- package/build/vatRatesData.js +42 -0
- package/build/vatRatesData.js.map +1 -0
- package/build/vendorAccountClient.d.ts +66 -0
- package/build/vendorAccountClient.d.ts.map +1 -0
- package/build/vendorAccountClient.js +47 -0
- package/build/vendorAccountClient.js.map +1 -0
- package/build/verifyCore.d.ts +4 -1
- package/build/verifyCore.d.ts.map +1 -0
- package/build/verifyCore.js +21 -10
- package/build/verifyCore.js.map +1 -0
- package/build/verifyOffline.d.ts +85 -0
- package/build/verifyOffline.d.ts.map +1 -0
- package/build/verifyOffline.js +139 -0
- package/build/verifyOffline.js.map +1 -0
- package/build/verifyService.d.ts.map +1 -0
- package/build/verifyService.js +571 -501
- package/build/verifyService.js.map +1 -0
- package/build/verifyWithAncestors.d.ts +19 -0
- package/build/verifyWithAncestors.d.ts.map +1 -0
- package/build/verifyWithAncestors.js +127 -0
- package/build/verifyWithAncestors.js.map +1 -0
- package/build/zipToState.d.ts +2 -0
- package/build/zipToState.d.ts.map +1 -0
- package/build/zipToState.js +94 -0
- package/build/zipToState.js.map +1 -0
- package/package.json +141 -16
- package/src/agent.ts +682 -0
- package/src/agentActions.ts +637 -0
- package/src/agentRecord.ts +121 -0
- package/src/agentVendor.ts +356 -0
- package/src/appsLookup.ts +48 -0
- package/src/archiveBootstrap.ts +386 -0
- package/src/atomicJson.ts +53 -0
- package/src/bindClient.ts +297 -0
- package/src/buyClient.ts +344 -0
- package/src/chainHead.ts +106 -0
- package/src/compactMerkleMap.ts +222 -0
- package/src/contractInterface.ts +465 -0
- package/src/contractTypes.ts +31 -0
- package/src/countryStats.ts +200 -0
- package/src/deployClient.ts +517 -0
- package/src/deviceIdentity.ts +249 -0
- package/src/freezeClient.ts +162 -0
- package/src/generationClient.ts +145 -0
- package/src/holderSecret.ts +91 -0
- package/src/index.ts +362 -0
- package/src/keysClient.ts +135 -0
- package/src/licenseLeaf.ts +221 -0
- package/src/licenseRecord.ts +190 -0
- package/src/licenseStore.ts +767 -0
- package/src/licenseTrustless.ts +207 -0
- package/src/manifestVerify.ts +164 -0
- package/src/minaPriceHistory.ts +166 -0
- package/src/onChainRoot.ts +90 -0
- package/src/ownershipLicenseHash.ts +31 -0
- package/src/ownershipSignature.ts +421 -0
- package/src/permitCheckFlow.ts +227 -0
- package/src/permitDescribe.ts +362 -0
- package/src/permitInterface.ts +146 -0
- package/src/permitMessages.ts +572 -0
- package/src/permitProof.ts +230 -0
- package/src/permitRegistryRebuild.ts +423 -0
- package/src/permitTree.ts +232 -0
- package/src/permitsClient.ts +746 -0
- package/src/proveErrors.ts +98 -0
- package/src/receipt.ts +156 -0
- package/src/refundClient.ts +177 -0
- package/src/registryTrustVerify.ts +192 -0
- package/src/releaseClient.ts +101 -0
- package/src/renewClient.ts +234 -0
- package/src/seatName.ts +165 -0
- package/src/slotConstants.ts +18 -0
- package/src/statsClient.ts +249 -0
- package/src/successorChain.ts +87 -0
- package/src/taxId.ts +227 -0
- package/src/trustAnchorVerify.ts +550 -0
- package/src/trustAnchorVerifyCli.ts +234 -0
- package/src/txBroadcastClient.ts +48 -0
- package/src/unbindClient.ts +233 -0
- package/src/units.ts +74 -0
- package/src/vatRates.ts +100 -0
- package/src/vatRatesData.ts +43 -0
- package/src/vendorAccountClient.ts +120 -0
- package/src/verifyCore.ts +100 -0
- package/src/verifyOffline.ts +212 -0
- package/src/verifyService.ts +982 -0
- package/src/verifyWithAncestors.ts +163 -0
- package/src/zipToState.ts +94 -0
- package/build/licenseProof.d.ts +0 -26
- package/build/licenseProof.js +0 -41
- package/build/migrateClient.d.ts +0 -43
- package/build/migrateClient.js +0 -47
- package/build/ownership.d.ts +0 -34
- package/build/ownership.js +0 -147
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* deviceIdentity.ts
|
|
6
|
+
*
|
|
7
|
+
* Per-install Pallas Schnorr keypair used to identify a device to the
|
|
8
|
+
* non-custodial binding flow. `getOrCreateDeviceIdentity(storageKey)`
|
|
9
|
+
* returns a stable keypair for the caller's storage key: created on
|
|
10
|
+
* first call, loaded verbatim on every subsequent call.
|
|
11
|
+
*
|
|
12
|
+
* The `deviceCommitment` this module exposes matches the on-chain
|
|
13
|
+
* `occupied[i]` field shape written by `LicensingApp.bind()` — it's
|
|
14
|
+
* `Poseidon([pk.x, pk.isOdd.toField()])` over the device's Pallas
|
|
15
|
+
* pubkey (see `ownerPubKeyHash` in ./licenseLeaf.ts for the identical
|
|
16
|
+
* shape applied to the ownership pubkey). Callers pass the commitment
|
|
17
|
+
* to `proveBind` and to the ownership signature; the raw device pubkey
|
|
18
|
+
* itself never crosses the wire.
|
|
19
|
+
*
|
|
20
|
+
* Storage tradeoffs (documented up front — the plan's original
|
|
21
|
+
* assumption was WebCrypto extractable=false, which only applies to
|
|
22
|
+
* WebCrypto-native primitives):
|
|
23
|
+
*
|
|
24
|
+
* Browser path — IndexedDB.
|
|
25
|
+
* Pallas is not a WebCrypto primitive, so we cannot store the
|
|
26
|
+
* private key as a non-extractable CryptoKey. The private key is
|
|
27
|
+
* persisted as its Mina canonical base58 encoding. An adversary
|
|
28
|
+
* with access to the browser profile (malware running as the user,
|
|
29
|
+
* evil browser extension) can read it. This matches the trust
|
|
30
|
+
* model of every other browser-persisted secret today (bearer
|
|
31
|
+
* cookies, IndexedDB tokens); binding to a fresh device after
|
|
32
|
+
* compromise is a keypair regen + one on-chain bind — no funds
|
|
33
|
+
* at risk, only slot churn. Users who want stronger storage can
|
|
34
|
+
* wipe the storageKey between sessions and re-bind on demand.
|
|
35
|
+
*
|
|
36
|
+
* Node / Electron path — keytar (OS keychain).
|
|
37
|
+
* `keytar` is loaded lazily and marked as an optional peer
|
|
38
|
+
* dependency in package.json so the browser bundle isn't dragged
|
|
39
|
+
* into installing native bindings it can't use. If keytar is
|
|
40
|
+
* unavailable at runtime, the SDK throws with a clear message
|
|
41
|
+
* rather than falling back to plaintext-on-disk.
|
|
42
|
+
*
|
|
43
|
+
* The keypair is Pallas-scalar-random, NOT passphrase-derived — the
|
|
44
|
+
* device identity is meant to survive a passphrase reset (the user
|
|
45
|
+
* can re-derive their ownership key from the passphrase, then unbind
|
|
46
|
+
* / rebind existing device slots without re-installing the app).
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { PrivateKey, PublicKey, Poseidon } from 'o1js';
|
|
50
|
+
|
|
51
|
+
// ── Storage key namespacing ───────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
// All device identities live under this shared prefix in whatever
|
|
54
|
+
// backing store is chosen. Callers pass a per-app / per-installation
|
|
55
|
+
// suffix (e.g. the app's short name) so a single browser profile can
|
|
56
|
+
// hold distinct device identities for distinct vendors' apps.
|
|
57
|
+
const STORAGE_PREFIX = 'zklic_device_';
|
|
58
|
+
|
|
59
|
+
function namespaced(storageKey: string): string {
|
|
60
|
+
return `${STORAGE_PREFIX}${storageKey}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── The identity shape callers get back ──────────────────────────
|
|
64
|
+
|
|
65
|
+
export type DeviceIdentity = {
|
|
66
|
+
devicePrivateKey: PrivateKey;
|
|
67
|
+
devicePubKey: PublicKey;
|
|
68
|
+
// Poseidon([pk.x, pk.isOdd.toField()]) as a decimal Field string.
|
|
69
|
+
// Same shape the on-chain `occupied[i]` slot stores, and the value
|
|
70
|
+
// /prove/bind expects in its request body. Callers pass this
|
|
71
|
+
// string directly — no further hashing at the call site.
|
|
72
|
+
deviceCommitment: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute the on-chain-shaped device commitment from a Pallas pubkey.
|
|
77
|
+
* Exported so tests and downstream tooling can re-derive it without
|
|
78
|
+
* re-instantiating the whole DeviceIdentity.
|
|
79
|
+
*/
|
|
80
|
+
export function computeDeviceCommitment(devicePubKey: PublicKey): string {
|
|
81
|
+
return Poseidon.hash([devicePubKey.x, devicePubKey.isOdd.toField()]).toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildIdentity(privateKey: PrivateKey): DeviceIdentity {
|
|
85
|
+
const devicePubKey = privateKey.toPublicKey();
|
|
86
|
+
return {
|
|
87
|
+
devicePrivateKey: privateKey,
|
|
88
|
+
devicePubKey,
|
|
89
|
+
deviceCommitment: computeDeviceCommitment(devicePubKey),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Environment detection ─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function hasIndexedDB(): boolean {
|
|
96
|
+
return typeof globalThis !== 'undefined'
|
|
97
|
+
&& typeof (globalThis as any).indexedDB !== 'undefined';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isNodeLike(): boolean {
|
|
101
|
+
// Node exposes `process.versions.node`; Deno/Bun don't (their
|
|
102
|
+
// process shims omit this key). Browsers have no `process` global
|
|
103
|
+
// unless something's polyfilled it, but the indexedDB branch runs
|
|
104
|
+
// first so that ordering is fine.
|
|
105
|
+
return typeof (globalThis as any).process !== 'undefined'
|
|
106
|
+
&& typeof (globalThis as any).process?.versions?.node === 'string';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── IndexedDB backing store (browser) ─────────────────────────────
|
|
110
|
+
|
|
111
|
+
// Small promisified wrapper — no third-party idb library, to keep the
|
|
112
|
+
// SDK dependency surface minimal. Single key/value store; the whole
|
|
113
|
+
// database is one object store with `storageKey` as the key.
|
|
114
|
+
const IDB_DB_NAME = 'zklicensing';
|
|
115
|
+
const IDB_STORE_NAME = 'device_identities';
|
|
116
|
+
const IDB_VERSION = 1;
|
|
117
|
+
|
|
118
|
+
function openIdb(): Promise<IDBDatabase> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const req = (globalThis as any).indexedDB.open(IDB_DB_NAME, IDB_VERSION);
|
|
121
|
+
req.onupgradeneeded = () => {
|
|
122
|
+
const db = req.result as IDBDatabase;
|
|
123
|
+
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
124
|
+
db.createObjectStore(IDB_STORE_NAME);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
req.onsuccess = () => resolve(req.result);
|
|
128
|
+
req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function idbGet(key: string): Promise<string | null> {
|
|
133
|
+
const db = await openIdb();
|
|
134
|
+
try {
|
|
135
|
+
return await new Promise<string | null>((resolve, reject) => {
|
|
136
|
+
const tx = db.transaction(IDB_STORE_NAME, 'readonly');
|
|
137
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
138
|
+
const req = store.get(key);
|
|
139
|
+
req.onsuccess = () => resolve((req.result as string | undefined) ?? null);
|
|
140
|
+
req.onerror = () => reject(req.error ?? new Error('IndexedDB get failed'));
|
|
141
|
+
});
|
|
142
|
+
} finally {
|
|
143
|
+
db.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function idbPut(key: string, value: string): Promise<void> {
|
|
148
|
+
const db = await openIdb();
|
|
149
|
+
try {
|
|
150
|
+
await new Promise<void>((resolve, reject) => {
|
|
151
|
+
const tx = db.transaction(IDB_STORE_NAME, 'readwrite');
|
|
152
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
153
|
+
const req = store.put(value, key);
|
|
154
|
+
req.onsuccess = () => resolve();
|
|
155
|
+
req.onerror = () => reject(req.error ?? new Error('IndexedDB put failed'));
|
|
156
|
+
});
|
|
157
|
+
} finally {
|
|
158
|
+
db.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── keytar backing store (Node / Electron) ────────────────────────
|
|
163
|
+
|
|
164
|
+
// Loaded via dynamic import so a browser bundler doesn't try to
|
|
165
|
+
// resolve the native module. If keytar isn't installed we throw with
|
|
166
|
+
// a message that tells the caller which optional peer dep to add,
|
|
167
|
+
// rather than silently persisting to plaintext.
|
|
168
|
+
async function loadKeytar(): Promise<any> {
|
|
169
|
+
try {
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
171
|
+
return await (Function('return import("keytar")')() as Promise<any>);
|
|
172
|
+
} catch {
|
|
173
|
+
throw new Error(
|
|
174
|
+
'deviceIdentity requires the optional peer dependency `keytar` in Node/Electron. ' +
|
|
175
|
+
'Install it with `npm i keytar` alongside the zklicensing SDK.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// keytar's shape: getPassword(service, account) / setPassword(service, account, value).
|
|
181
|
+
// We use a single service name and let `storageKey` be the account, so
|
|
182
|
+
// multiple apps under the same node install can coexist under distinct
|
|
183
|
+
// storage keys without stomping each other.
|
|
184
|
+
const KEYTAR_SERVICE = 'zklicensing-device-identity';
|
|
185
|
+
|
|
186
|
+
async function keytarGet(key: string): Promise<string | null> {
|
|
187
|
+
const keytar = await loadKeytar();
|
|
188
|
+
const mod = (keytar.default ?? keytar) as { getPassword: (s: string, a: string) => Promise<string | null> };
|
|
189
|
+
return mod.getPassword(KEYTAR_SERVICE, key);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function keytarPut(key: string, value: string): Promise<void> {
|
|
193
|
+
const keytar = await loadKeytar();
|
|
194
|
+
const mod = (keytar.default ?? keytar) as { setPassword: (s: string, a: string, p: string) => Promise<void> };
|
|
195
|
+
await mod.setPassword(KEYTAR_SERVICE, key, value);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Backend selection ────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
type Backend = {
|
|
201
|
+
get: (key: string) => Promise<string | null>;
|
|
202
|
+
put: (key: string, value: string) => Promise<void>;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function pickBackend(): Backend {
|
|
206
|
+
if (hasIndexedDB()) return { get: idbGet, put: idbPut };
|
|
207
|
+
if (isNodeLike()) return { get: keytarGet, put: keytarPut };
|
|
208
|
+
throw new Error(
|
|
209
|
+
'deviceIdentity: no suitable storage backend detected. ' +
|
|
210
|
+
'Expected IndexedDB in the browser or a Node/Electron runtime with `keytar` installed.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Public API ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Load an existing device keypair for `storageKey`, or create + persist
|
|
218
|
+
* a fresh one on first call. Idempotent across every subsequent call
|
|
219
|
+
* for the same key on the same install.
|
|
220
|
+
*
|
|
221
|
+
* The `storageKey` should be per-app (e.g. the vendor's app short
|
|
222
|
+
* name or its `appId`), so a single browser profile / OS user account
|
|
223
|
+
* can hold distinct device identities for distinct vendors without
|
|
224
|
+
* cross-contamination.
|
|
225
|
+
*/
|
|
226
|
+
export async function getOrCreateDeviceIdentity(storageKey: string): Promise<DeviceIdentity> {
|
|
227
|
+
if (!storageKey || typeof storageKey !== 'string') {
|
|
228
|
+
throw new Error('getOrCreateDeviceIdentity: storageKey must be a non-empty string');
|
|
229
|
+
}
|
|
230
|
+
const backend = pickBackend();
|
|
231
|
+
const key = namespaced(storageKey);
|
|
232
|
+
|
|
233
|
+
const existing = await backend.get(key);
|
|
234
|
+
if (existing) {
|
|
235
|
+
try {
|
|
236
|
+
const privateKey = PrivateKey.fromBase58(existing);
|
|
237
|
+
return buildIdentity(privateKey);
|
|
238
|
+
} catch {
|
|
239
|
+
// Corrupt persisted value — fall through and mint a fresh key,
|
|
240
|
+
// overwriting the bad blob. Losing a device identity here is
|
|
241
|
+
// recoverable: the caller re-binds the new keypair and unbinds
|
|
242
|
+
// the old slot from any other device that still has the
|
|
243
|
+
// ownership key.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const privateKey = PrivateKey.random();
|
|
247
|
+
await backend.put(key, privateKey.toBase58());
|
|
248
|
+
return buildIdentity(privateKey);
|
|
249
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* freezeClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Client-side view of the backend's sales-freeze schedule. A freeze is set
|
|
8
|
+
* ahead of a hardfork so every open escrow window can drain (refund or
|
|
9
|
+
* release) before v1 → v2 migration. Once past the freeze slot the backend
|
|
10
|
+
* refuses new buys and renewals; refunds and verify remain open.
|
|
11
|
+
*
|
|
12
|
+
* Resolution order for buy/renew clients that want to know the current
|
|
13
|
+
* freeze state:
|
|
14
|
+
* 1. Caller-supplied `freezeAfterSlot` (explicit override — build-time
|
|
15
|
+
* constant, or pulled from the vendor's own env at boot).
|
|
16
|
+
* 2. `process.env.FREEZE_AFTER_SLOT` when running under Node (server-side
|
|
17
|
+
* integrators / CLIs).
|
|
18
|
+
* 3. Backend's own `/health` payload — the authoritative source of truth.
|
|
19
|
+
*
|
|
20
|
+
* A `freezeAfterSlot` of 0 / null / undefined means "no freeze scheduled".
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type NetworkHealth = {
|
|
24
|
+
graphqlUrl?: string;
|
|
25
|
+
archiveUrl?: string;
|
|
26
|
+
slot: number;
|
|
27
|
+
salesFrozen: boolean;
|
|
28
|
+
freezeAfterSlot: number | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// GET /health returns one block per configured network:
|
|
32
|
+
// { status: 'ok', devnet?: NetworkHealth, mainnet?: NetworkHealth }
|
|
33
|
+
// Older keepers returned a single flat block; those fields stay optional here
|
|
34
|
+
// so both shapes typecheck. Use pickNetworkHealth() rather than reading
|
|
35
|
+
// either shape directly.
|
|
36
|
+
export type KeeperHealth = {
|
|
37
|
+
status: string;
|
|
38
|
+
devnet?: NetworkHealth;
|
|
39
|
+
mainnet?: NetworkHealth;
|
|
40
|
+
/** @deprecated legacy flat shape */
|
|
41
|
+
network?: string;
|
|
42
|
+
/** @deprecated legacy flat shape */
|
|
43
|
+
slot?: number;
|
|
44
|
+
/** @deprecated legacy flat shape */
|
|
45
|
+
salesFrozen?: boolean;
|
|
46
|
+
/** @deprecated legacy flat shape */
|
|
47
|
+
freezeAfterSlot?: number | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The health block for `network`. Without a network: the only configured
|
|
52
|
+
* one, or null when several are configured (ambiguous). Falls back to the
|
|
53
|
+
* legacy flat shape.
|
|
54
|
+
*/
|
|
55
|
+
export function pickNetworkHealth(health: KeeperHealth, network?: 'devnet' | 'mainnet'): NetworkHealth | null {
|
|
56
|
+
const nested = (['devnet', 'mainnet'] as const).filter(n => health[n] && typeof health[n]!.slot === 'number');
|
|
57
|
+
if (nested.length > 0) {
|
|
58
|
+
if (network) return health[network] ?? null;
|
|
59
|
+
return nested.length === 1 ? health[nested[0]]! : null;
|
|
60
|
+
}
|
|
61
|
+
if (typeof health.slot === 'number' || typeof health.salesFrozen === 'boolean') {
|
|
62
|
+
return {
|
|
63
|
+
slot: typeof health.slot === 'number' ? health.slot : 0,
|
|
64
|
+
salesFrozen: health.salesFrozen === true,
|
|
65
|
+
freezeAfterSlot: typeof health.freezeAfterSlot === 'number' ? health.freezeAfterSlot : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type FreezeState = {
|
|
72
|
+
// True when the current slot is past freezeAfterSlot — new buys and
|
|
73
|
+
// renewals are refused backend-side.
|
|
74
|
+
salesFrozen: boolean;
|
|
75
|
+
// Scheduled freeze slot (announced ahead of a hardfork). Null when no
|
|
76
|
+
// freeze is configured. Buy clients should require an explicit
|
|
77
|
+
// refund-window waiver only when the buyer's 14-day refund window would
|
|
78
|
+
// extend past this slot (i.e. currentSlot + REFUND_WINDOW_N > freezeAfterSlot).
|
|
79
|
+
freezeAfterSlot: number | null;
|
|
80
|
+
// Chain slot at resolution time. Populated from /health; null on the
|
|
81
|
+
// caller-override / env paths where the slot is not known client-side.
|
|
82
|
+
// Callers computing refund-window overlap should treat null
|
|
83
|
+
// conservatively (require the waiver) — the backend's own guard is still
|
|
84
|
+
// the last line of defence.
|
|
85
|
+
currentSlot: number | null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function baseUrl(url: string): string {
|
|
89
|
+
return url.replace(/\/+$/, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function envFreezeSlot(): number | null {
|
|
93
|
+
// Runs both in Node (process.env) and browser (no `process`). The typeof
|
|
94
|
+
// guard keeps bundlers from choking on the reference.
|
|
95
|
+
if (typeof process === 'undefined' || !process.env) return null;
|
|
96
|
+
const raw = process.env.FREEZE_AFTER_SLOT;
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const n = parseInt(raw, 10);
|
|
99
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type GetKeeperHealthOptions = {
|
|
103
|
+
keeperUrl: string;
|
|
104
|
+
fetcher?: typeof fetch;
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// GET /health — raw payload. Callers that only need the freeze state
|
|
109
|
+
// should use resolveFreezeState() instead; this is exposed for UIs that
|
|
110
|
+
// surface slot / network info alongside freeze.
|
|
111
|
+
export async function getKeeperHealth(opts: GetKeeperHealthOptions): Promise<KeeperHealth> {
|
|
112
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
113
|
+
const resp = await fetcher(`${baseUrl(opts.keeperUrl)}/health`, { signal: opts.signal });
|
|
114
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
115
|
+
return await resp.json() as KeeperHealth;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type ResolveFreezeStateOptions = {
|
|
119
|
+
keeperUrl: string;
|
|
120
|
+
// Network the action targets. Needed to read the right block of a
|
|
121
|
+
// multi-network /health response.
|
|
122
|
+
network?: 'devnet' | 'mainnet';
|
|
123
|
+
// Explicit override (highest precedence). Null/undefined falls through
|
|
124
|
+
// to env, then /health.
|
|
125
|
+
freezeAfterSlot?: number | null;
|
|
126
|
+
fetcher?: typeof fetch;
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Resolves the effective freeze state for the caller. When the source is
|
|
131
|
+
// the caller override or process.env, the current slot is not known
|
|
132
|
+
// client-side, so salesFrozen is filled from /health only — the override
|
|
133
|
+
// path returns { freezeAfterSlot: N, salesFrozen: false, currentSlot: null }
|
|
134
|
+
// and lets the backend reject the actual prove call if the freeze has fired.
|
|
135
|
+
export async function resolveFreezeState(opts: ResolveFreezeStateOptions): Promise<FreezeState> {
|
|
136
|
+
if (typeof opts.freezeAfterSlot === 'number' && opts.freezeAfterSlot > 0) {
|
|
137
|
+
return { salesFrozen: false, freezeAfterSlot: opts.freezeAfterSlot, currentSlot: null };
|
|
138
|
+
}
|
|
139
|
+
const envSlot = envFreezeSlot();
|
|
140
|
+
if (envSlot !== null) {
|
|
141
|
+
return { salesFrozen: false, freezeAfterSlot: envSlot, currentSlot: null };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const health = await getKeeperHealth({
|
|
145
|
+
keeperUrl: opts.keeperUrl,
|
|
146
|
+
fetcher: opts.fetcher,
|
|
147
|
+
signal: opts.signal,
|
|
148
|
+
});
|
|
149
|
+
const net = pickNetworkHealth(health, opts.network);
|
|
150
|
+
if (!net) return { salesFrozen: false, freezeAfterSlot: null, currentSlot: null };
|
|
151
|
+
return {
|
|
152
|
+
salesFrozen: net.salesFrozen === true,
|
|
153
|
+
freezeAfterSlot: typeof net.freezeAfterSlot === 'number' && net.freezeAfterSlot > 0 ? net.freezeAfterSlot : null,
|
|
154
|
+
currentSlot: typeof net.slot === 'number' && net.slot > 0 ? net.slot : null,
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
// /health unreachable — treat as no freeze so we don't block buys on a
|
|
158
|
+
// transient network hiccup. The backend's own guard is still the last
|
|
159
|
+
// line of defense.
|
|
160
|
+
return { salesFrozen: false, freezeAfterSlot: null, currentSlot: null };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* generationClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Browser-safe fetcher for the backend's signed generation-list. Returns the
|
|
8
|
+
* app's deployment history — every past address plus the current live one —
|
|
9
|
+
* verified against a caller-supplied pin list of platform manifest public
|
|
10
|
+
* keys.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: the backend is not the trust anchor for this data — it can
|
|
13
|
+
* be re-hosted, mirrored, cached, or (worst case) impersonated. The pin
|
|
14
|
+
* list is. Any response that fails signature verification is surfaced as
|
|
15
|
+
* `verified: false` with `generations: []` so callers cannot accidentally
|
|
16
|
+
* route a wallet prompt to an unverified address.
|
|
17
|
+
*
|
|
18
|
+
* Optional corroboration channel: an operator that publishes
|
|
19
|
+
* https://<vendor-host>/.well-known/zklicensing/<zkAppAddress>.json
|
|
20
|
+
* (using the same backend-signed payload) can be cross-checked from the
|
|
21
|
+
* on-chain zkappUri. One file per deployed address (so per generation),
|
|
22
|
+
* because a vendor may host several apps at one origin. The cross-check is opt-in per-app — a 404 at that
|
|
23
|
+
* URL is not a failure signal, just "no corroboration published for this
|
|
24
|
+
* app." That's the caller's job, not this module's.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { verifyManifest } from './manifestVerify.js';
|
|
28
|
+
|
|
29
|
+
export interface GenerationEntry {
|
|
30
|
+
gen: number;
|
|
31
|
+
zkAppAddress: string;
|
|
32
|
+
status: 'retired' | 'live';
|
|
33
|
+
frozenRoot?: string;
|
|
34
|
+
deployedAt?: string;
|
|
35
|
+
retiredAt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GenerationInfo {
|
|
39
|
+
/** Live-generation index (matches the entry with status: 'live'). Null when unverified or missing. */
|
|
40
|
+
current: number | null;
|
|
41
|
+
generations: GenerationEntry[];
|
|
42
|
+
/** True iff signature verified against one of the pinned pubkeys. */
|
|
43
|
+
verified: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FetchGenerationRequest {
|
|
47
|
+
keeperUrl: string;
|
|
48
|
+
network: 'devnet' | 'mainnet';
|
|
49
|
+
appId: string;
|
|
50
|
+
/** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
|
|
51
|
+
pinnedPublicKeysBase64: readonly string[];
|
|
52
|
+
fetcher?: typeof fetch;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FetchGenerationByAddressRequest {
|
|
57
|
+
keeperUrl: string;
|
|
58
|
+
/** When given, a list signed for the other network is refused. */
|
|
59
|
+
network?: 'devnet' | 'mainnet';
|
|
60
|
+
/** Any zkApp address in the app's generation chain — current live or any retired. */
|
|
61
|
+
zkAppAddress: string;
|
|
62
|
+
/** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
|
|
63
|
+
pinnedPublicKeysBase64: readonly string[];
|
|
64
|
+
fetcher?: typeof fetch;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function baseUrl(url: string): string {
|
|
69
|
+
return url.replace(/\/+$/, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The verified generation list in a keeper response: a platform signature
|
|
74
|
+
* from a pinned key (current or, during a key rotation, the previous one)
|
|
75
|
+
* over the signed fields, for `network` when given. Unverified data is
|
|
76
|
+
* never surfaced: a caller must not route a wallet prompt to an address we
|
|
77
|
+
* couldn't authenticate.
|
|
78
|
+
*/
|
|
79
|
+
async function verifyAndProject(
|
|
80
|
+
body: unknown,
|
|
81
|
+
pinnedPublicKeysBase64: readonly string[],
|
|
82
|
+
network?: 'devnet' | 'mainnet',
|
|
83
|
+
): Promise<GenerationInfo> {
|
|
84
|
+
const signed = await verifyManifest(body, pinnedPublicKeysBase64, network ? { network } : {});
|
|
85
|
+
if (!signed) return { current: null, generations: [], verified: false };
|
|
86
|
+
return { current: signed.current, generations: signed.generations as GenerationEntry[], verified: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* GET /apps/:appId — returns { appId, current, generations, issuedAt, publicKey, signature }.
|
|
91
|
+
* We reconstruct the canonical payload (everything except `publicKey` +
|
|
92
|
+
* `signature`) and verify against the pin list. `publicKey` in the response
|
|
93
|
+
* is advisory only — it's the key the backend claims to have signed with;
|
|
94
|
+
* the SDK never trusts it in place of a pin.
|
|
95
|
+
*/
|
|
96
|
+
export async function fetchGenerationInfo(req: FetchGenerationRequest): Promise<GenerationInfo> {
|
|
97
|
+
const fetcher = req.fetcher ?? fetch;
|
|
98
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/apps/${encodeURIComponent(req.appId)}?network=${req.network}`, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
signal: req.signal,
|
|
101
|
+
});
|
|
102
|
+
if (!resp.ok) {
|
|
103
|
+
return { current: null, generations: [], verified: false };
|
|
104
|
+
}
|
|
105
|
+
let body: Record<string, unknown>;
|
|
106
|
+
try { body = await resp.json() as Record<string, unknown>; }
|
|
107
|
+
catch { return { current: null, generations: [], verified: false }; }
|
|
108
|
+
|
|
109
|
+
return verifyAndProject(body, req.pinnedPublicKeysBase64, req.network);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* GET /apps/by-address/:zkAppAddress — same signed payload as fetchGenerationInfo,
|
|
114
|
+
* but keyed by an address the caller already has (direct-paste buys, renew on a
|
|
115
|
+
* since-retired address). Buyers arriving without an appId would otherwise have
|
|
116
|
+
* to trust the backend's unsigned `/apps` list to bind their address to an appId
|
|
117
|
+
* first — defeating the pin.
|
|
118
|
+
*
|
|
119
|
+
* Extra assertion beyond the appId flow: the queried address MUST appear in the
|
|
120
|
+
* returned `generations[]`. Without this, a malicious backend could return a
|
|
121
|
+
* validly-signed payload for a DIFFERENT app in response to the query, and the
|
|
122
|
+
* signature check alone wouldn't catch it.
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchGenerationInfoByAddress(
|
|
125
|
+
req: FetchGenerationByAddressRequest,
|
|
126
|
+
): Promise<GenerationInfo> {
|
|
127
|
+
const fetcher = req.fetcher ?? fetch;
|
|
128
|
+
const resp = await fetcher(
|
|
129
|
+
`${baseUrl(req.keeperUrl)}/apps/by-address/${encodeURIComponent(req.zkAppAddress)}`,
|
|
130
|
+
{ method: 'GET', signal: req.signal },
|
|
131
|
+
);
|
|
132
|
+
if (!resp.ok) {
|
|
133
|
+
return { current: null, generations: [], verified: false };
|
|
134
|
+
}
|
|
135
|
+
let body: Record<string, unknown>;
|
|
136
|
+
try { body = await resp.json() as Record<string, unknown>; }
|
|
137
|
+
catch { return { current: null, generations: [], verified: false }; }
|
|
138
|
+
|
|
139
|
+
const projected = await verifyAndProject(body, req.pinnedPublicKeysBase64, req.network);
|
|
140
|
+
if (!projected.verified) return projected;
|
|
141
|
+
if (!projected.generations.some(g => g.zkAppAddress === req.zkAppAddress)) {
|
|
142
|
+
return { current: null, generations: [], verified: false };
|
|
143
|
+
}
|
|
144
|
+
return projected;
|
|
145
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* holderSecret.ts — the permit holder's secret for anonymous proofs
|
|
6
|
+
* (see permitProof.ts). Light: no ZkProgram, so clients can derive it when
|
|
7
|
+
* applying without loading the proof.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Field, Poseidon, PublicKey } from 'o1js';
|
|
11
|
+
import { networkField, permitSubject, type PermitNetwork } from './permitMessages.js';
|
|
12
|
+
|
|
13
|
+
/** Tags for the holder secret's signed message and for the salt derived from it. */
|
|
14
|
+
export const HOLDER_SECRET_TAG: Field = Field([...'zkPermits/v1/holder-secret'].reduce((v, ch) => (v << 8n) | BigInt(ch.charCodeAt(0)), 0n));
|
|
15
|
+
export const HOLDER_SALT_TAG: Field = Field([...'zkPermits/v1/holder-salt'].reduce((v, ch) => (v << 8n) | BigInt(ch.charCodeAt(0)), 0n));
|
|
16
|
+
|
|
17
|
+
/** How many counter values recovery tries: one key rarely holds more permits in one registry. */
|
|
18
|
+
export const MAX_HOLDER_SECRETS = 16;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the holder's wallet signs (a text signature, signMessage) to derive a
|
|
22
|
+
* secret: the registry's lineage, its network and a counter: 0 for the
|
|
23
|
+
* key's first permit in the registry, 1 for the next, and so on. The
|
|
24
|
+
* lineage is the registry's first generation's address (the keeper's
|
|
25
|
+
* `registry.lineage`; the address itself for a registry never redeployed),
|
|
26
|
+
* so a permit's secret stays the same when the registry moves to a new
|
|
27
|
+
* address. Readable in the wallet, and nothing needs to be kept: the holder
|
|
28
|
+
* re-derives the secret from the chain and the wallet alone (findHolderSecret).
|
|
29
|
+
*/
|
|
30
|
+
export function holderSecretText(lineage: string, network: PermitNetwork, index: number): string {
|
|
31
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new Error('holder secret index must be a non-negative integer');
|
|
32
|
+
PublicKey.fromBase58(lineage);
|
|
33
|
+
networkField(network);
|
|
34
|
+
return [
|
|
35
|
+
'zkPermits: recover my permit secret',
|
|
36
|
+
`registry: ${lineage}`,
|
|
37
|
+
`network: ${network}`,
|
|
38
|
+
`permit number: ${index}`,
|
|
39
|
+
'Sign this only on zkLicensing pages: it lets the signer recognise this permit\'s anonymous proofs.',
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The holder secret from the wallet's (deterministic) text signature over holderSecretText. */
|
|
44
|
+
export function holderSecretFromSignature(sig: { field: string; scalar: string }): Field {
|
|
45
|
+
const scalar = BigInt(sig.scalar);
|
|
46
|
+
const low = scalar & ((1n << 128n) - 1n);
|
|
47
|
+
return Poseidon.hash([HOLDER_SECRET_TAG, Field(BigInt(sig.field)), Field(scalar >> 128n), Field(low)]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The permit's salt: public to the keeper and the authority, and reveals nothing about the secret. */
|
|
51
|
+
export function saltFromHolderSecret(holderSecret: Field): Field {
|
|
52
|
+
return Poseidon.hash([HOLDER_SALT_TAG, holderSecret]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type TextWallet = { address: string; signMessage(message: string): Promise<{ field: string; scalar: string }> };
|
|
56
|
+
|
|
57
|
+
/** Derives the secret, salt and permit subject for counter `index` (one wallet signature). */
|
|
58
|
+
export async function deriveHolderSecret(
|
|
59
|
+
wallet: TextWallet,
|
|
60
|
+
args: { lineage: string; network: PermitNetwork; index: number },
|
|
61
|
+
): Promise<{ secret: string; salt: string; subject: string; index: number }> {
|
|
62
|
+
const signature = await wallet.signMessage(holderSecretText(args.lineage, args.network, args.index));
|
|
63
|
+
const secret = holderSecretFromSignature(signature);
|
|
64
|
+
const salt = saltFromHolderSecret(secret);
|
|
65
|
+
return { secret: secret.toString(), salt: salt.toString(), subject: permitSubject(PublicKey.fromBase58(wallet.address), salt).toString(), index: args.index };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Recovers the secret of the permit `subject` held by this wallet: signs for
|
|
70
|
+
* counter 0, 1, 2… until one gives that subject (usually the first). Throws
|
|
71
|
+
* when none does: another wallet, or a permit issued before secrets were
|
|
72
|
+
* derived this way.
|
|
73
|
+
*/
|
|
74
|
+
export async function findHolderSecret(
|
|
75
|
+
wallet: TextWallet,
|
|
76
|
+
args: { lineage: string; network: PermitNetwork; subject: string; maxTries?: number },
|
|
77
|
+
): Promise<{ secret: string; index: number }> {
|
|
78
|
+
for (let index = 0; index < (args.maxTries ?? MAX_HOLDER_SECRETS); index++) {
|
|
79
|
+
const d = await deriveHolderSecret(wallet, { lineage: args.lineage, network: args.network, index });
|
|
80
|
+
if (d.subject === args.subject) return { secret: d.secret, index };
|
|
81
|
+
}
|
|
82
|
+
throw new Error('This wallet derives no secret for this permit: is it the wallet the permit was applied for with, on this network?');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* True when `holderSecret` belongs to the permit (subject = H(holderKey, salt(secret))).
|
|
87
|
+
* Check before proving, for a clear error instead of a failed proof.
|
|
88
|
+
*/
|
|
89
|
+
export function holderSecretMatches(holderKey: string, subject: string, holderSecret: string): boolean {
|
|
90
|
+
return permitSubject(PublicKey.fromBase58(holderKey), saltFromHolderSecret(Field(holderSecret))).toString() === subject;
|
|
91
|
+
}
|