@zerc20/storage-sdk 0.0.0-stage → 0.2.0
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 +24 -0
- package/README.md +14 -2
- package/dist/candid.d.ts +73 -0
- package/dist/candid.js +51 -0
- package/dist/candid.js.map +1 -0
- package/dist/canister.d.ts +33 -0
- package/dist/canister.js +76 -0
- package/dist/canister.js.map +1 -0
- package/dist/crypto.d.ts +27 -0
- package/dist/crypto.js +99 -0
- package/dist/crypto.js.map +1 -0
- package/dist/formats.d.ts +202 -0
- package/dist/formats.js +238 -0
- package/dist/formats.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/powWorker.d.ts +1 -0
- package/dist/powWorker.js +4 -0
- package/dist/powWorker.js.map +1 -0
- package/dist/send.d.ts +51 -0
- package/dist/send.js +162 -0
- package/dist/send.js.map +1 -0
- package/dist/sync.d.ts +82 -0
- package/dist/sync.js +263 -0
- package/dist/sync.js.map +1 -0
- package/dist/viewKey.d.ts +45 -0
- package/dist/viewKey.js +106 -0
- package/dist/viewKey.js.map +1 -0
- package/dist/wire.d.ts +33 -0
- package/dist/wire.js +112 -0
- package/dist/wire.js.map +1 -0
- package/package.json +51 -4
- package/src/candid.ts +116 -0
- package/src/canister.ts +85 -0
- package/src/crypto.ts +113 -0
- package/src/formats.ts +399 -0
- package/src/index.ts +86 -0
- package/src/powWorker.ts +6 -0
- package/src/send.ts +224 -0
- package/src/sync.ts +376 -0
- package/src/viewKey.ts +154 -0
- package/src/wire.ts +136 -0
package/src/viewKey.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// View keys and registration.
|
|
2
|
+
//
|
|
3
|
+
// An address's view key derives from the root seed of the address's zERC20 identity, so restoring a wallet needs
|
|
4
|
+
// nothing from the canister. It derives from the canister's id too: a testnet and mainnet share root seeds, and a key
|
|
5
|
+
// escrowed in one canister, which that canister's auditor, controllers and vetKD key can open, must open nothing in
|
|
6
|
+
// another. Registering (one wallet signature) publishes what payers need: the address's zERC20 public key and the key
|
|
7
|
+
// payers encrypt channel announcements to. It also escrows the view key to the auditor, whom vetKD alone can give the
|
|
8
|
+
// key to it. A later registration, from another root seed, replaces it.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DerivedPublicKey,
|
|
12
|
+
EncryptedVetKey,
|
|
13
|
+
IbeCiphertext,
|
|
14
|
+
IbeIdentity,
|
|
15
|
+
IbeSeed,
|
|
16
|
+
TransportSecretKey,
|
|
17
|
+
} from '@dfinity/vetkeys';
|
|
18
|
+
import { x25519 } from '@noble/curves/ed25519.js';
|
|
19
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
20
|
+
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
21
|
+
import type { Principal } from '@icp-sdk/core/principal';
|
|
22
|
+
import type { Registration } from './candid.js';
|
|
23
|
+
import type { CanisterClient } from './canister.js';
|
|
24
|
+
import { compare, concat, equal, kdf, nowNs, utf8 } from './crypto.js';
|
|
25
|
+
import { FR_BE, addressInbox } from './formats.js';
|
|
26
|
+
import { MAX_TTL_NS, registerMessage, vetkdInput } from './wire.js';
|
|
27
|
+
|
|
28
|
+
/** EIP-191 `personal_sign` by the address's wallet: `r ‖ s ‖ v`, 65 bytes. */
|
|
29
|
+
export type Signer = (message: string) => Promise<Uint8Array>;
|
|
30
|
+
|
|
31
|
+
/** An address's view key: the seed everything owner-only derives from. */
|
|
32
|
+
export interface ViewKey {
|
|
33
|
+
address: Uint8Array;
|
|
34
|
+
seed: Uint8Array;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `address`'s view key in `canister`, from the root seed of the address's zERC20 identity (in zERC20-v2, `keccak256`
|
|
38
|
+
* of a canonical `personal_sign` signature). */
|
|
39
|
+
export function viewKey(rootSeed: Uint8Array, address: Uint8Array, canister: Principal): ViewKey {
|
|
40
|
+
if (rootSeed.length !== 32) throw new Error('the root seed must be 32 bytes');
|
|
41
|
+
return { address, seed: kdf(rootSeed, 'view', [address, canister.toUint8Array()]) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The X25519 secret that opens announcements to `view`'s address inbox. */
|
|
45
|
+
export const notifySecret = (view: ViewKey): Uint8Array => kdf(view.seed, 'notify');
|
|
46
|
+
|
|
47
|
+
/** The key payers encrypt announcements to, which names `view`'s registration. */
|
|
48
|
+
export const notifyKey = (view: ViewKey): Uint8Array => x25519.getPublicKey(notifySecret(view));
|
|
49
|
+
|
|
50
|
+
/** `view`'s address registered with `publicKey`, the zERC20 public key payments to it are to be owned by. The first
|
|
51
|
+
* check is a query, answered by one replica: only a missing or foreign registration is confirmed with an update call
|
|
52
|
+
* before registering (one wallet signature) or throwing. Throws if the address is registered with another root seed;
|
|
53
|
+
* `register` to take it over. */
|
|
54
|
+
export async function registeredViewKey(
|
|
55
|
+
client: CanisterClient,
|
|
56
|
+
rootSeed: Uint8Array,
|
|
57
|
+
signer: Signer,
|
|
58
|
+
address: Uint8Array,
|
|
59
|
+
publicKey: Uint8Array,
|
|
60
|
+
): Promise<ViewKey> {
|
|
61
|
+
const view = viewKey(rootSeed, address, client.canisterId);
|
|
62
|
+
const ours = (r: Registration | null) => r && equal(r.notify_key, notifyKey(view)) && equal(r.public_key, publicKey);
|
|
63
|
+
if (ours(await client.registration(address))) return view;
|
|
64
|
+
const [r] = await client.replicated.registration(address, []);
|
|
65
|
+
if (r && !equal(r.notify_key, notifyKey(view))) {
|
|
66
|
+
throw new Error('the address is registered with another root seed: register to take it over');
|
|
67
|
+
}
|
|
68
|
+
if (!ours(r ?? null)) await register(client, signer, view, publicKey);
|
|
69
|
+
return view;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Registers `view`'s address with `publicKey`, replacing whatever it registered before. */
|
|
73
|
+
export async function register(
|
|
74
|
+
client: CanisterClient,
|
|
75
|
+
signer: Signer,
|
|
76
|
+
view: ViewKey,
|
|
77
|
+
publicKey: Uint8Array,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
if (publicKey.length !== 32) throw new Error('a public key is 32 bytes');
|
|
80
|
+
const notify = notifyKey(view);
|
|
81
|
+
const identity = IbeIdentity.fromBytes(vetkdInput(view.address, notify));
|
|
82
|
+
const r: Registration = {
|
|
83
|
+
address: view.address,
|
|
84
|
+
public_key: publicKey,
|
|
85
|
+
notify_key: notify,
|
|
86
|
+
escrow: IbeCiphertext.encrypt(await client.escrowPublicKey(), identity, view.seed, IbeSeed.random()).serialize(),
|
|
87
|
+
expiry_ns: nowNs() + MAX_TTL_NS / 2n,
|
|
88
|
+
signature: new Uint8Array(),
|
|
89
|
+
};
|
|
90
|
+
r.signature = await signer(registerMessage(client.canisterId, r));
|
|
91
|
+
await client.register(r);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The address that signed `message` with EIP-191 `personal_sign`, or `null`. */
|
|
95
|
+
export function eip191Signer(message: string, signature: Uint8Array): Uint8Array | null {
|
|
96
|
+
if (signature.length !== 65) return null;
|
|
97
|
+
const v = signature[64] >= 27 ? signature[64] - 27 : signature[64];
|
|
98
|
+
if (v > 1) return null;
|
|
99
|
+
const msg = utf8(message);
|
|
100
|
+
const digest = keccak_256(concat(utf8(`\x19Ethereum Signed Message:\n${msg.length}`), msg));
|
|
101
|
+
try {
|
|
102
|
+
const sig = secp256k1.Signature.fromBytes(concat(Uint8Array.of(v), signature.subarray(0, 64)), 'recovered');
|
|
103
|
+
if (sig.hasHighS()) return null;
|
|
104
|
+
return keccak_256(sig.recoverPublicKey(digest).toBytes(false).subarray(1)).slice(12);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether `r` is what `address` signed: the canister checks this on write, and a payer checks it again so that a
|
|
111
|
+
* canister cannot point it at keys of its own. */
|
|
112
|
+
export const verifyRegistration = (canister: Principal, address: Uint8Array, r: Registration): boolean =>
|
|
113
|
+
equal(r.address, address) &&
|
|
114
|
+
equal(eip191Signer(registerMessage(canister, r), r.signature) ?? new Uint8Array(), address);
|
|
115
|
+
|
|
116
|
+
/** Whom a payment to an address goes to: the zERC20 public key to own the note, and the inbox and key to announce a
|
|
117
|
+
* channel to. */
|
|
118
|
+
export interface Recipient {
|
|
119
|
+
address: Uint8Array;
|
|
120
|
+
publicKey: Uint8Array;
|
|
121
|
+
notifyKey: Uint8Array;
|
|
122
|
+
inbox: Uint8Array;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `address` as a recipient, checked against its owner's signature, or `null` when it never registered: pay it
|
|
126
|
+
* publicly instead, or ask for a receive code. An update call, so that no single replica can answer with a
|
|
127
|
+
* registration the address has replaced; a canister can withhold one, but not forge one. */
|
|
128
|
+
export async function lookupRecipient(client: CanisterClient, address: Uint8Array): Promise<Recipient | null> {
|
|
129
|
+
const [r] = await client.replicated.registration(address, []);
|
|
130
|
+
if (!r || !verifyRegistration(client.canisterId, address, r) || compare(r.public_key, FR_BE) >= 0) return null;
|
|
131
|
+
return { address, publicKey: r.public_key, notifyKey: r.notify_key, inbox: addressInbox(address, r.notify_key) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** For the auditor: the view key in `registration`'s escrow, from what `audit_escrow_key` returned (encrypted to
|
|
135
|
+
* `transportKey`). `null` if the key or the escrow does not open. */
|
|
136
|
+
export function openEscrow(
|
|
137
|
+
encrypted: Uint8Array,
|
|
138
|
+
transportKey: TransportSecretKey,
|
|
139
|
+
escrowPublicKey: DerivedPublicKey,
|
|
140
|
+
registration: Registration,
|
|
141
|
+
): ViewKey | null {
|
|
142
|
+
const { address, notify_key } = registration;
|
|
143
|
+
try {
|
|
144
|
+
const vetKey = EncryptedVetKey.deserialize(encrypted).decryptAndVerify(
|
|
145
|
+
transportKey,
|
|
146
|
+
escrowPublicKey,
|
|
147
|
+
vetkdInput(address, notify_key),
|
|
148
|
+
);
|
|
149
|
+
const seed = IbeCiphertext.deserialize(registration.escrow).decrypt(vetKey);
|
|
150
|
+
return seed.length === 32 ? { address, seed } : null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Bytes the canister checks: signed messages and proof-of-work digests, and the vetKD input. They must match
|
|
2
|
+
// canister/src/registry.rs, audit.rs and store.rs byte for byte; test/wire.test.ts pins the same fixtures as the
|
|
3
|
+
// canister's `wire_fixtures` test.
|
|
4
|
+
|
|
5
|
+
import type { Principal } from '@icp-sdk/core/principal';
|
|
6
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
7
|
+
import type { Action, KindName, Registration } from './candid.js';
|
|
8
|
+
import { concat, hex, lengthPrefixed, taggedHash, u64be, utf8 } from './crypto.js';
|
|
9
|
+
|
|
10
|
+
/** Sized for a five-year-old budget Android phone (the canister's `lib.rs` says how). */
|
|
11
|
+
export const POW_BITS = 14;
|
|
12
|
+
/** Address inboxes are public, so opening a channel costs more work than a plain write. */
|
|
13
|
+
export const OPEN_POW_BITS = 17;
|
|
14
|
+
export const REGISTER_POW_BITS = 18;
|
|
15
|
+
/** Every blob is exactly this long, so its length tells nothing of what it holds. */
|
|
16
|
+
export const BLOB_LEN = 256;
|
|
17
|
+
export const MAX_READ = 256;
|
|
18
|
+
/** Lane slot ids per `get_log` call. */
|
|
19
|
+
export const MAX_LOG_READ = 8192;
|
|
20
|
+
/** Longest a signed request stays valid. */
|
|
21
|
+
export const MAX_TTL_NS = 600_000_000_000n;
|
|
22
|
+
|
|
23
|
+
/** The canister's `Kind` numbers (Rust's, not the .did's alphabetical order). */
|
|
24
|
+
const KIND_CODE: Record<KindName, number> = { Outgoing: 0, Lane: 1 };
|
|
25
|
+
|
|
26
|
+
/** Tags a registration's proof-of-work digest and the text its owner signs. */
|
|
27
|
+
const REGISTRATION_TAG = 'zerc20/storage/registration/v1';
|
|
28
|
+
|
|
29
|
+
/** The vetKD input, which is also the IBE identity, of the key to the escrow of `address`'s registration with
|
|
30
|
+
* `notifyKey`. */
|
|
31
|
+
export const vetkdInput = (address: Uint8Array, notifyKey: Uint8Array): Uint8Array => concat(address, notifyKey);
|
|
32
|
+
|
|
33
|
+
/** What an address owner signs (EIP-191) to register. */
|
|
34
|
+
export function registerMessage(canister: Principal, r: Registration): string {
|
|
35
|
+
return (
|
|
36
|
+
`zERC20 storage registration\ncanister: ${canister.toText()}\naddress: 0x${hex(r.address)}\n` +
|
|
37
|
+
`public_key: 0x${hex(r.public_key)}\nnotify_key: 0x${hex(r.notify_key)}\n` +
|
|
38
|
+
`escrow_sha256: 0x${hex(sha256(r.escrow))}\nexpiry_ns: ${r.expiry_ns}\ntag: ${REGISTRATION_TAG}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the auditor signs (EIP-191) to receive the key to the escrow of `target`'s registration with `notifyKey`. */
|
|
43
|
+
export function auditMessage(
|
|
44
|
+
canister: Principal,
|
|
45
|
+
target: Uint8Array,
|
|
46
|
+
notifyKey: Uint8Array,
|
|
47
|
+
transportPublicKey: Uint8Array,
|
|
48
|
+
expiryNs: bigint,
|
|
49
|
+
): string {
|
|
50
|
+
return (
|
|
51
|
+
`zERC20 storage audit request\ncanister: ${canister.toText()}\ntarget: 0x${hex(target)}\n` +
|
|
52
|
+
`notify_key: 0x${hex(notifyKey)}\ntransport_public_key: 0x${hex(transportPublicKey)}\nexpiry_ns: ${expiryNs}\n` +
|
|
53
|
+
'tag: zerc20/storage/audit/v1'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerDigest(r: Registration): Uint8Array {
|
|
58
|
+
return taggedHash(REGISTRATION_TAG, [r.address, r.public_key, r.notify_key, r.escrow, u64be(r.expiry_ns)]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function actionsDigest(actions: Action[]): Uint8Array {
|
|
62
|
+
const parts = actions.flatMap((action) => {
|
|
63
|
+
if ('OpenChannel' in action) {
|
|
64
|
+
const { inbox, enc_announcement, outgoing_id, enc_outgoing } = action.OpenChannel;
|
|
65
|
+
return [Uint8Array.of(0), lengthPrefixed([inbox, enc_announcement, outgoing_id, enc_outgoing])];
|
|
66
|
+
}
|
|
67
|
+
const { kind, id, data } = action.Put;
|
|
68
|
+
const code = KIND_CODE[Object.keys(kind)[0] as KindName];
|
|
69
|
+
return [Uint8Array.of(1, code), lengthPrefixed([id, data])];
|
|
70
|
+
});
|
|
71
|
+
return sha256(concat(...parts));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const requiredPowBits = (actions: Action[]): number =>
|
|
75
|
+
actions.some((action) => 'OpenChannel' in action) ? OPEN_POW_BITS : POW_BITS;
|
|
76
|
+
|
|
77
|
+
const POW_TAG = utf8('zerc20/storage/pow/v1');
|
|
78
|
+
|
|
79
|
+
function leadingZeroBits(bytes: Uint8Array): number {
|
|
80
|
+
let zeros = 0;
|
|
81
|
+
for (const byte of bytes) {
|
|
82
|
+
if (byte !== 0) return zeros + Math.clz32(byte) - 24;
|
|
83
|
+
zeros += 8;
|
|
84
|
+
}
|
|
85
|
+
return zeros;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function powOk(digest: Uint8Array, nonce: bigint, bits: number): boolean {
|
|
89
|
+
return leadingZeroBits(sha256(concat(POW_TAG, digest, u64be(nonce)))) >= bits;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The first nonce whose proof of work reaches `bits`, searched in a Web Worker so a page stays
|
|
93
|
+
* responsive. Where there is none (Node), or it does not start (a bundler that left out
|
|
94
|
+
* powWorker.js), on this thread, yielding every ~100 ms. */
|
|
95
|
+
export async function solvePow(digest: Uint8Array, bits: number): Promise<bigint> {
|
|
96
|
+
if (typeof Worker !== 'undefined') {
|
|
97
|
+
try {
|
|
98
|
+
return await inWorker(digest, bits);
|
|
99
|
+
} catch {
|
|
100
|
+
// searched here instead
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return findNonce(digest, bits, 100);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function inWorker(digest: Uint8Array, bits: number): Promise<bigint> {
|
|
107
|
+
// this exact form is what bundlers (Vite, webpack) look for to emit the worker
|
|
108
|
+
const worker = new Worker(new URL('./powWorker.js', import.meta.url), { type: 'module' });
|
|
109
|
+
try {
|
|
110
|
+
return await new Promise<bigint>((resolve, reject) => {
|
|
111
|
+
worker.onmessage = ({ data }: MessageEvent<bigint>) => resolve(data);
|
|
112
|
+
worker.onerror = reject;
|
|
113
|
+
worker.postMessage({ digest, bits });
|
|
114
|
+
});
|
|
115
|
+
} finally {
|
|
116
|
+
worker.terminate();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The first nonce whose proof of work reaches `bits`. On a page's thread, yields to the event loop
|
|
121
|
+
* every `yieldMs`, and no more often: each yield is a timer, which costs 4 ms in a visible tab and a
|
|
122
|
+
* whole second in a hidden one. */
|
|
123
|
+
export async function findNonce(digest: Uint8Array, bits: number, yieldMs: number): Promise<bigint> {
|
|
124
|
+
const buffer = concat(POW_TAG, digest, new Uint8Array(8));
|
|
125
|
+
const nonce = new DataView(buffer.buffer, POW_TAG.length + digest.length, 8);
|
|
126
|
+
let since = Date.now();
|
|
127
|
+
for (let n = 0; ; n++) {
|
|
128
|
+
nonce.setUint32(0, Math.floor(n / 2 ** 32), false);
|
|
129
|
+
nonce.setUint32(4, n >>> 0, false);
|
|
130
|
+
if (leadingZeroBits(sha256(buffer)) >= bits) return BigInt(n);
|
|
131
|
+
if (n % 4096 === 4095 && Date.now() - since >= yieldMs) {
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
133
|
+
since = Date.now();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|