@zi2/relay-sdk 1.0.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/CHANGELOG.md +22 -0
- package/LICENSE +64 -0
- package/README.md +855 -0
- package/dist/adapters/prisma-adapter.d.ts +57 -0
- package/dist/adapters/prisma-adapter.js +181 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +298 -0
- package/dist/index.d.ts +592 -0
- package/dist/index.js +2537 -0
- package/dist/server/fastify-plugin.d.ts +26 -0
- package/dist/server/fastify-plugin.js +125 -0
- package/dist/types-CPJUrmcy.d.ts +288 -0
- package/package.json +75 -0
- package/prisma/schema.prisma +89 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { D as DatabaseAdapter, P as PhoneRelayRecord, c as CreateRelayInput, d as PhoneRelayPairingRecord, e as CreatePairingInput, R as RelayMessageRecord, f as CreateMessageInput } from '../types-CPJUrmcy.js';
|
|
2
|
+
import 'ws';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic Prisma-like client type — avoids a hard dependency on @prisma/client.
|
|
6
|
+
* Any ORM / query-builder that exposes the same delegate shape will work.
|
|
7
|
+
*/
|
|
8
|
+
type PrismaDelegate = {
|
|
9
|
+
findUnique(args: any): Promise<any>;
|
|
10
|
+
findFirst(args: any): Promise<any>;
|
|
11
|
+
findMany(args: any): Promise<any>;
|
|
12
|
+
create(args: any): Promise<any>;
|
|
13
|
+
update(args: any): Promise<any>;
|
|
14
|
+
updateMany(args: any): Promise<any>;
|
|
15
|
+
delete(args: any): Promise<any>;
|
|
16
|
+
deleteMany(args: any): Promise<any>;
|
|
17
|
+
count(args?: any): Promise<number>;
|
|
18
|
+
};
|
|
19
|
+
type PrismaLike = {
|
|
20
|
+
phoneRelay: PrismaDelegate;
|
|
21
|
+
phoneRelayPairing: PrismaDelegate;
|
|
22
|
+
relayMessageQueue: PrismaDelegate;
|
|
23
|
+
$transaction?: (ops: Promise<any>[]) => Promise<any[]>;
|
|
24
|
+
};
|
|
25
|
+
declare class PrismaAdapter implements DatabaseAdapter {
|
|
26
|
+
private prisma;
|
|
27
|
+
constructor(prisma: PrismaLike);
|
|
28
|
+
findRelay(id: string, orgId?: string): Promise<PhoneRelayRecord | null>;
|
|
29
|
+
findRelayByTokenHash(id: string, tokenHash: string, statuses: string[]): Promise<PhoneRelayRecord | null>;
|
|
30
|
+
findRelays(orgId: string, excludeStatus?: string): Promise<PhoneRelayRecord[]>;
|
|
31
|
+
createRelay(data: CreateRelayInput): Promise<PhoneRelayRecord>;
|
|
32
|
+
updateRelay(id: string, data: Partial<PhoneRelayRecord>): Promise<void>;
|
|
33
|
+
updateRelayByOrg(id: string, orgId: string, data: Partial<PhoneRelayRecord>): Promise<void>;
|
|
34
|
+
deleteRelay(id: string): Promise<void>;
|
|
35
|
+
incrementRelayStat(id: string, field: 'totalSmsSent' | 'totalSmsFailed' | 'dailySmsSent', amount?: number): Promise<void>;
|
|
36
|
+
countRelays(orgId: string): Promise<number>;
|
|
37
|
+
markDegradedRelays(threshold: Date): Promise<number>;
|
|
38
|
+
resetDailyCounters(): Promise<number>;
|
|
39
|
+
findPairing(id: string): Promise<PhoneRelayPairingRecord | null>;
|
|
40
|
+
createPairing(data: CreatePairingInput): Promise<PhoneRelayPairingRecord>;
|
|
41
|
+
updatePairingStatus(id: string, status: string): Promise<void>;
|
|
42
|
+
deleteExpiredPairings(): Promise<number>;
|
|
43
|
+
findMessage(id: string): Promise<RelayMessageRecord | null>;
|
|
44
|
+
createMessage(data: CreateMessageInput): Promise<RelayMessageRecord>;
|
|
45
|
+
updateMessage(id: string, data: Partial<RelayMessageRecord>): Promise<void>;
|
|
46
|
+
findPendingMessages(relayId: string, limit: number): Promise<RelayMessageRecord[]>;
|
|
47
|
+
findMessages(relayId: string, limit: number, offset: number): Promise<{
|
|
48
|
+
messages: RelayMessageRecord[];
|
|
49
|
+
total: number;
|
|
50
|
+
}>;
|
|
51
|
+
deleteMessagesByRelay(relayId: string): Promise<number>;
|
|
52
|
+
expireStaleMessages(): Promise<number>;
|
|
53
|
+
createMessages(batch: CreateMessageInput[]): Promise<RelayMessageRecord[]>;
|
|
54
|
+
updateMessages(ids: string[], data: Partial<RelayMessageRecord>): Promise<number>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { PrismaAdapter, type PrismaLike };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var RELAY_STATUS = {
|
|
3
|
+
ACTIVE: "active",
|
|
4
|
+
INACTIVE: "inactive",
|
|
5
|
+
REVOKED: "revoked",
|
|
6
|
+
DEGRADED: "degraded"
|
|
7
|
+
};
|
|
8
|
+
var RELAY_LIMITS = {
|
|
9
|
+
AUTH_TIMEOUT_MS: 5e3,
|
|
10
|
+
HEARTBEAT_INTERVAL_MS: 3e4,
|
|
11
|
+
PAIRING_EXPIRY_MINUTES: 5,
|
|
12
|
+
MESSAGE_EXPIRY_HOURS: 24,
|
|
13
|
+
QUEUE_DRAIN_DELAY_MS: 3e3,
|
|
14
|
+
DEFAULT_SMS_TIMEOUT_MS: 3e4,
|
|
15
|
+
MAX_SMS_PER_MINUTE: 20,
|
|
16
|
+
MAX_SMS_PER_HOUR: 200,
|
|
17
|
+
MAX_SMS_PER_DAY: 1e3,
|
|
18
|
+
DEGRADED_THRESHOLD_MS: 5 * 60 * 1e3
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/adapters/prisma-adapter.ts
|
|
22
|
+
var PrismaAdapter = class {
|
|
23
|
+
constructor(prisma) {
|
|
24
|
+
this.prisma = prisma;
|
|
25
|
+
}
|
|
26
|
+
// ── PhoneRelay CRUD ─────────────────────────────────────────────────
|
|
27
|
+
async findRelay(id, orgId) {
|
|
28
|
+
if (orgId) {
|
|
29
|
+
return this.prisma.phoneRelay.findFirst({ where: { id, organizationId: orgId } });
|
|
30
|
+
}
|
|
31
|
+
return this.prisma.phoneRelay.findUnique({ where: { id } });
|
|
32
|
+
}
|
|
33
|
+
async findRelayByTokenHash(id, tokenHash, statuses) {
|
|
34
|
+
return this.prisma.phoneRelay.findFirst({
|
|
35
|
+
where: {
|
|
36
|
+
id,
|
|
37
|
+
authTokenHash: tokenHash,
|
|
38
|
+
status: { in: statuses }
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async findRelays(orgId, excludeStatus) {
|
|
43
|
+
const where = { organizationId: orgId };
|
|
44
|
+
if (excludeStatus) {
|
|
45
|
+
where.status = { not: excludeStatus };
|
|
46
|
+
}
|
|
47
|
+
return this.prisma.phoneRelay.findMany({
|
|
48
|
+
where,
|
|
49
|
+
orderBy: { createdAt: "desc" }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async createRelay(data) {
|
|
53
|
+
return this.prisma.phoneRelay.create({ data });
|
|
54
|
+
}
|
|
55
|
+
async updateRelay(id, data) {
|
|
56
|
+
await this.prisma.phoneRelay.update({ where: { id }, data });
|
|
57
|
+
}
|
|
58
|
+
async updateRelayByOrg(id, orgId, data) {
|
|
59
|
+
await this.prisma.phoneRelay.updateMany({
|
|
60
|
+
where: { id, organizationId: orgId },
|
|
61
|
+
data
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async deleteRelay(id) {
|
|
65
|
+
await this.prisma.phoneRelay.delete({ where: { id } });
|
|
66
|
+
}
|
|
67
|
+
async incrementRelayStat(id, field, amount = 1) {
|
|
68
|
+
await this.prisma.phoneRelay.update({
|
|
69
|
+
where: { id },
|
|
70
|
+
data: { [field]: { increment: amount } }
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
async countRelays(orgId) {
|
|
74
|
+
return this.prisma.phoneRelay.count({
|
|
75
|
+
where: { organizationId: orgId, status: { not: RELAY_STATUS.REVOKED } }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async markDegradedRelays(threshold) {
|
|
79
|
+
const result = await this.prisma.phoneRelay.updateMany({
|
|
80
|
+
where: {
|
|
81
|
+
status: RELAY_STATUS.ACTIVE,
|
|
82
|
+
lastSeenAt: { lt: threshold }
|
|
83
|
+
},
|
|
84
|
+
data: { status: RELAY_STATUS.DEGRADED }
|
|
85
|
+
});
|
|
86
|
+
return result.count;
|
|
87
|
+
}
|
|
88
|
+
async resetDailyCounters() {
|
|
89
|
+
const result = await this.prisma.phoneRelay.updateMany({
|
|
90
|
+
where: {
|
|
91
|
+
status: { in: [RELAY_STATUS.ACTIVE, RELAY_STATUS.DEGRADED] }
|
|
92
|
+
},
|
|
93
|
+
data: { dailySmsSent: 0, dailyResetAt: /* @__PURE__ */ new Date() }
|
|
94
|
+
});
|
|
95
|
+
return result.count;
|
|
96
|
+
}
|
|
97
|
+
// ── PhoneRelayPairing ───────────────────────────────────────────────
|
|
98
|
+
async findPairing(id) {
|
|
99
|
+
return this.prisma.phoneRelayPairing.findUnique({ where: { id } });
|
|
100
|
+
}
|
|
101
|
+
async createPairing(data) {
|
|
102
|
+
return this.prisma.phoneRelayPairing.create({ data });
|
|
103
|
+
}
|
|
104
|
+
async updatePairingStatus(id, status) {
|
|
105
|
+
await this.prisma.phoneRelayPairing.update({ where: { id }, data: { status } });
|
|
106
|
+
}
|
|
107
|
+
async deleteExpiredPairings() {
|
|
108
|
+
const result = await this.prisma.phoneRelayPairing.deleteMany({
|
|
109
|
+
where: { expiresAt: { lt: /* @__PURE__ */ new Date() }, status: "pending" }
|
|
110
|
+
});
|
|
111
|
+
return result.count;
|
|
112
|
+
}
|
|
113
|
+
// ── RelayMessageQueue ───────────────────────────────────────────────
|
|
114
|
+
async findMessage(id) {
|
|
115
|
+
return this.prisma.relayMessageQueue.findUnique({ where: { id } });
|
|
116
|
+
}
|
|
117
|
+
async createMessage(data) {
|
|
118
|
+
return this.prisma.relayMessageQueue.create({ data });
|
|
119
|
+
}
|
|
120
|
+
async updateMessage(id, data) {
|
|
121
|
+
await this.prisma.relayMessageQueue.update({ where: { id }, data });
|
|
122
|
+
}
|
|
123
|
+
async findPendingMessages(relayId, limit) {
|
|
124
|
+
return this.prisma.relayMessageQueue.findMany({
|
|
125
|
+
where: {
|
|
126
|
+
relayId,
|
|
127
|
+
status: "pending",
|
|
128
|
+
expiresAt: { gt: /* @__PURE__ */ new Date() }
|
|
129
|
+
},
|
|
130
|
+
orderBy: { createdAt: "asc" },
|
|
131
|
+
take: limit
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async findMessages(relayId, limit, offset) {
|
|
135
|
+
const [messages, total] = await Promise.all([
|
|
136
|
+
this.prisma.relayMessageQueue.findMany({
|
|
137
|
+
where: { relayId },
|
|
138
|
+
orderBy: { createdAt: "desc" },
|
|
139
|
+
take: limit,
|
|
140
|
+
skip: offset
|
|
141
|
+
}),
|
|
142
|
+
this.prisma.relayMessageQueue.count({ where: { relayId } })
|
|
143
|
+
]);
|
|
144
|
+
return { messages, total };
|
|
145
|
+
}
|
|
146
|
+
async deleteMessagesByRelay(relayId) {
|
|
147
|
+
const result = await this.prisma.relayMessageQueue.deleteMany({
|
|
148
|
+
where: { relayId }
|
|
149
|
+
});
|
|
150
|
+
return result.count;
|
|
151
|
+
}
|
|
152
|
+
async expireStaleMessages() {
|
|
153
|
+
const result = await this.prisma.relayMessageQueue.updateMany({
|
|
154
|
+
where: {
|
|
155
|
+
expiresAt: { lt: /* @__PURE__ */ new Date() },
|
|
156
|
+
status: { in: ["pending", "sent_to_device"] }
|
|
157
|
+
},
|
|
158
|
+
data: { status: "expired" }
|
|
159
|
+
});
|
|
160
|
+
return result.count;
|
|
161
|
+
}
|
|
162
|
+
// ── Batch operations ────────────────────────────────────────────────
|
|
163
|
+
async createMessages(batch) {
|
|
164
|
+
if (this.prisma.$transaction) {
|
|
165
|
+
return this.prisma.$transaction(
|
|
166
|
+
batch.map((data) => this.prisma.relayMessageQueue.create({ data }))
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return Promise.all(batch.map((data) => this.prisma.relayMessageQueue.create({ data })));
|
|
170
|
+
}
|
|
171
|
+
async updateMessages(ids, data) {
|
|
172
|
+
const result = await this.prisma.relayMessageQueue.updateMany({
|
|
173
|
+
where: { id: { in: ids } },
|
|
174
|
+
data
|
|
175
|
+
});
|
|
176
|
+
return result.count;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
export {
|
|
180
|
+
PrismaAdapter
|
|
181
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type MessageHandler = (message: any) => void;
|
|
2
|
+
declare class ReconnectingWebSocket {
|
|
3
|
+
private ws;
|
|
4
|
+
private url;
|
|
5
|
+
private relayId;
|
|
6
|
+
private authToken;
|
|
7
|
+
private onMessage;
|
|
8
|
+
private onStateChange;
|
|
9
|
+
private reconnectDelay;
|
|
10
|
+
private maxReconnectDelay;
|
|
11
|
+
private reconnectTimer;
|
|
12
|
+
private intentionallyClosed;
|
|
13
|
+
constructor(options: {
|
|
14
|
+
url: string;
|
|
15
|
+
relayId: string;
|
|
16
|
+
authToken: string;
|
|
17
|
+
onMessage: MessageHandler;
|
|
18
|
+
onStateChange: (state: 'connecting' | 'connected' | 'disconnected') => void;
|
|
19
|
+
});
|
|
20
|
+
connect(): void;
|
|
21
|
+
private scheduleReconnect;
|
|
22
|
+
send(data: any): void;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare function generateX25519KeyPair(): Promise<{
|
|
27
|
+
publicKey: string;
|
|
28
|
+
privateKey: string;
|
|
29
|
+
}>;
|
|
30
|
+
declare function deriveSharedKey(privateKeyHex: string, serverPublicKeySpkiHex: string): Promise<string>;
|
|
31
|
+
declare function decryptE2E(ciphertext: string, sharedKeyHex: string): Promise<string>;
|
|
32
|
+
declare function encryptE2E(plaintext: string, sharedKeyHex: string): Promise<string>;
|
|
33
|
+
|
|
34
|
+
interface QrPayload {
|
|
35
|
+
v: number;
|
|
36
|
+
pid: string;
|
|
37
|
+
spk: string;
|
|
38
|
+
pt: string;
|
|
39
|
+
url: string;
|
|
40
|
+
}
|
|
41
|
+
declare function parseQrPayload(data: string): QrPayload;
|
|
42
|
+
declare function completePairing(qr: QrPayload, deviceName: string, platform: string, phoneNumber?: string): Promise<any>;
|
|
43
|
+
|
|
44
|
+
declare function requestSmsPermission(): Promise<boolean>;
|
|
45
|
+
declare function sendNativeSms(to: string, body: string): Promise<boolean>;
|
|
46
|
+
|
|
47
|
+
declare function getStoredCredentials(): Promise<{
|
|
48
|
+
relayId: string;
|
|
49
|
+
authToken: string;
|
|
50
|
+
sharedKey: string;
|
|
51
|
+
serverUrl: string;
|
|
52
|
+
} | null>;
|
|
53
|
+
declare function storeCredentials(creds: {
|
|
54
|
+
relayId: string;
|
|
55
|
+
authToken: string;
|
|
56
|
+
sharedKey: string;
|
|
57
|
+
serverUrl: string;
|
|
58
|
+
}): Promise<void>;
|
|
59
|
+
declare function storeDevicePrivateKey(key: string): Promise<void>;
|
|
60
|
+
declare function getDevicePrivateKey(): Promise<string | null>;
|
|
61
|
+
declare function clearAllData(): Promise<void>;
|
|
62
|
+
|
|
63
|
+
export { ReconnectingWebSocket, clearAllData, completePairing, decryptE2E, deriveSharedKey, encryptE2E, generateX25519KeyPair, getDevicePrivateKey, getStoredCredentials, parseQrPayload, requestSmsPermission, sendNativeSms, storeCredentials, storeDevicePrivateKey };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var RELAY_MESSAGE_TYPES = {
|
|
3
|
+
AUTH: "auth",
|
|
4
|
+
SEND_SMS: "send_sms",
|
|
5
|
+
SMS_ACK: "sms_ack",
|
|
6
|
+
DELIVERY_RECEIPT: "delivery_receipt",
|
|
7
|
+
PONG: "pong",
|
|
8
|
+
STATUS: "status",
|
|
9
|
+
REKEY: "rekey",
|
|
10
|
+
REKEY_ACK: "rekey_ack"
|
|
11
|
+
};
|
|
12
|
+
var RELAY_LIMITS = {
|
|
13
|
+
AUTH_TIMEOUT_MS: 5e3,
|
|
14
|
+
HEARTBEAT_INTERVAL_MS: 3e4,
|
|
15
|
+
PAIRING_EXPIRY_MINUTES: 5,
|
|
16
|
+
MESSAGE_EXPIRY_HOURS: 24,
|
|
17
|
+
QUEUE_DRAIN_DELAY_MS: 3e3,
|
|
18
|
+
DEFAULT_SMS_TIMEOUT_MS: 3e4,
|
|
19
|
+
MAX_SMS_PER_MINUTE: 20,
|
|
20
|
+
MAX_SMS_PER_HOUR: 200,
|
|
21
|
+
MAX_SMS_PER_DAY: 1e3,
|
|
22
|
+
DEGRADED_THRESHOLD_MS: 5 * 60 * 1e3
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/client/websocket-client.ts
|
|
26
|
+
var ReconnectingWebSocket = class {
|
|
27
|
+
ws = null;
|
|
28
|
+
url;
|
|
29
|
+
relayId;
|
|
30
|
+
authToken;
|
|
31
|
+
onMessage;
|
|
32
|
+
onStateChange;
|
|
33
|
+
reconnectDelay = 1e3;
|
|
34
|
+
maxReconnectDelay = 6e4;
|
|
35
|
+
reconnectTimer = null;
|
|
36
|
+
intentionallyClosed = false;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.url = options.url;
|
|
39
|
+
this.relayId = options.relayId;
|
|
40
|
+
this.authToken = options.authToken;
|
|
41
|
+
this.onMessage = options.onMessage;
|
|
42
|
+
this.onStateChange = options.onStateChange;
|
|
43
|
+
}
|
|
44
|
+
connect() {
|
|
45
|
+
this.intentionallyClosed = false;
|
|
46
|
+
this.onStateChange("connecting");
|
|
47
|
+
try {
|
|
48
|
+
this.ws = new WebSocket(this.url);
|
|
49
|
+
} catch {
|
|
50
|
+
this.scheduleReconnect();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.ws.onopen = () => {
|
|
54
|
+
this.reconnectDelay = 1e3;
|
|
55
|
+
this.ws?.send(JSON.stringify({
|
|
56
|
+
type: RELAY_MESSAGE_TYPES.AUTH,
|
|
57
|
+
relayId: this.relayId,
|
|
58
|
+
token: this.authToken,
|
|
59
|
+
ts: Date.now()
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
this.ws.onmessage = (event) => {
|
|
63
|
+
try {
|
|
64
|
+
const message = JSON.parse(event.data);
|
|
65
|
+
if (message.type === "auth_ok") {
|
|
66
|
+
this.onStateChange("connected");
|
|
67
|
+
}
|
|
68
|
+
this.onMessage(message);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
this.ws.onclose = () => {
|
|
73
|
+
this.onStateChange("disconnected");
|
|
74
|
+
if (!this.intentionallyClosed) {
|
|
75
|
+
this.scheduleReconnect();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
this.ws.onerror = () => {
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
scheduleReconnect() {
|
|
82
|
+
if (this.intentionallyClosed) return;
|
|
83
|
+
const jitter = this.reconnectDelay * (0.8 + Math.random() * 0.4);
|
|
84
|
+
this.reconnectTimer = setTimeout(() => this.connect(), jitter);
|
|
85
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
|
|
86
|
+
}
|
|
87
|
+
send(data) {
|
|
88
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
89
|
+
this.ws.send(JSON.stringify(data));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
close() {
|
|
93
|
+
this.intentionallyClosed = true;
|
|
94
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
95
|
+
this.ws?.close();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// src/client/e2e-crypto.ts
|
|
100
|
+
var E2E_HKDF_INFO = "zi2-relay-e2e-v1";
|
|
101
|
+
function hexToBytes(hex) {
|
|
102
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
103
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
104
|
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
105
|
+
}
|
|
106
|
+
return bytes;
|
|
107
|
+
}
|
|
108
|
+
function bytesToHex(bytes) {
|
|
109
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
110
|
+
}
|
|
111
|
+
async function generateX25519KeyPair() {
|
|
112
|
+
const keyPair = await crypto.subtle.generateKey({ name: "X25519" }, true, ["deriveBits"]);
|
|
113
|
+
const publicRaw = await crypto.subtle.exportKey("spki", keyPair.publicKey);
|
|
114
|
+
const privateRaw = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
|
|
115
|
+
return {
|
|
116
|
+
publicKey: bytesToHex(new Uint8Array(publicRaw)),
|
|
117
|
+
privateKey: bytesToHex(new Uint8Array(privateRaw))
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function deriveSharedKey(privateKeyHex, serverPublicKeySpkiHex) {
|
|
121
|
+
const privateKeyBytes = hexToBytes(privateKeyHex);
|
|
122
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", privateKeyBytes.buffer, { name: "X25519" }, false, ["deriveBits"]);
|
|
123
|
+
const publicKeyBytes = hexToBytes(serverPublicKeySpkiHex);
|
|
124
|
+
const publicKey = await crypto.subtle.importKey("spki", publicKeyBytes.buffer, { name: "X25519" }, false, []);
|
|
125
|
+
const sharedSecret = await crypto.subtle.deriveBits({ name: "X25519", public: publicKey }, privateKey, 256);
|
|
126
|
+
const hkdfKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "HKDF" }, false, ["deriveBits"]);
|
|
127
|
+
const infoBytes = new TextEncoder().encode(E2E_HKDF_INFO);
|
|
128
|
+
const derivedBits = await crypto.subtle.deriveBits(
|
|
129
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0).buffer, info: infoBytes.buffer },
|
|
130
|
+
hkdfKey,
|
|
131
|
+
256
|
|
132
|
+
);
|
|
133
|
+
return bytesToHex(new Uint8Array(derivedBits));
|
|
134
|
+
}
|
|
135
|
+
async function decryptE2E(ciphertext, sharedKeyHex) {
|
|
136
|
+
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(":");
|
|
137
|
+
if (!ivHex || !authTagHex || !encryptedHex) throw new Error("Invalid ciphertext format");
|
|
138
|
+
const iv = hexToBytes(ivHex);
|
|
139
|
+
const authTag = hexToBytes(authTagHex);
|
|
140
|
+
const encrypted = hexToBytes(encryptedHex);
|
|
141
|
+
const combined = new Uint8Array(encrypted.length + authTag.length);
|
|
142
|
+
combined.set(encrypted);
|
|
143
|
+
combined.set(authTag, encrypted.length);
|
|
144
|
+
const keyBytes = hexToBytes(sharedKeyHex);
|
|
145
|
+
const key = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
146
|
+
const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv.buffer }, key, combined.buffer);
|
|
147
|
+
return new TextDecoder().decode(decrypted);
|
|
148
|
+
}
|
|
149
|
+
async function encryptE2E(plaintext, sharedKeyHex) {
|
|
150
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
151
|
+
const keyBytes = hexToBytes(sharedKeyHex);
|
|
152
|
+
const key = await crypto.subtle.importKey("raw", keyBytes.buffer, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
153
|
+
const plaintextBytes = new TextEncoder().encode(plaintext);
|
|
154
|
+
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer, tagLength: 128 }, key, plaintextBytes.buffer);
|
|
155
|
+
const encryptedBytes = new Uint8Array(encrypted);
|
|
156
|
+
const cipherBytes = encryptedBytes.slice(0, encryptedBytes.length - 16);
|
|
157
|
+
const tagBytes = encryptedBytes.slice(encryptedBytes.length - 16);
|
|
158
|
+
return `${bytesToHex(iv)}:${bytesToHex(tagBytes)}:${bytesToHex(cipherBytes)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/client/secure-storage.ts
|
|
162
|
+
import { Preferences } from "@capacitor/preferences";
|
|
163
|
+
var KEYS = {
|
|
164
|
+
RELAY_ID: "zi2_relay_id",
|
|
165
|
+
AUTH_TOKEN: "zi2_auth_token",
|
|
166
|
+
SHARED_KEY: "zi2_shared_key",
|
|
167
|
+
SERVER_URL: "zi2_server_url",
|
|
168
|
+
DEVICE_PRIVATE_KEY: "zi2_device_private_key"
|
|
169
|
+
};
|
|
170
|
+
async function getStoredCredentials() {
|
|
171
|
+
const [relayId, authToken, sharedKey, serverUrl] = await Promise.all([
|
|
172
|
+
Preferences.get({ key: KEYS.RELAY_ID }),
|
|
173
|
+
Preferences.get({ key: KEYS.AUTH_TOKEN }),
|
|
174
|
+
Preferences.get({ key: KEYS.SHARED_KEY }),
|
|
175
|
+
Preferences.get({ key: KEYS.SERVER_URL })
|
|
176
|
+
]);
|
|
177
|
+
if (!relayId.value || !authToken.value || !sharedKey.value || !serverUrl.value) return null;
|
|
178
|
+
return {
|
|
179
|
+
relayId: relayId.value,
|
|
180
|
+
authToken: authToken.value,
|
|
181
|
+
sharedKey: sharedKey.value,
|
|
182
|
+
serverUrl: serverUrl.value
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function storeCredentials(creds) {
|
|
186
|
+
await Promise.all([
|
|
187
|
+
Preferences.set({ key: KEYS.RELAY_ID, value: creds.relayId }),
|
|
188
|
+
Preferences.set({ key: KEYS.AUTH_TOKEN, value: creds.authToken }),
|
|
189
|
+
Preferences.set({ key: KEYS.SHARED_KEY, value: creds.sharedKey }),
|
|
190
|
+
Preferences.set({ key: KEYS.SERVER_URL, value: creds.serverUrl })
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
async function storeDevicePrivateKey(key) {
|
|
194
|
+
await Preferences.set({ key: KEYS.DEVICE_PRIVATE_KEY, value: key });
|
|
195
|
+
}
|
|
196
|
+
async function getDevicePrivateKey() {
|
|
197
|
+
const result = await Preferences.get({ key: KEYS.DEVICE_PRIVATE_KEY });
|
|
198
|
+
return result.value;
|
|
199
|
+
}
|
|
200
|
+
async function clearAllData() {
|
|
201
|
+
await Preferences.clear();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/client/pairing.ts
|
|
205
|
+
function parseQrPayload(data) {
|
|
206
|
+
const parsed = JSON.parse(data);
|
|
207
|
+
if (parsed.v !== 1) throw new Error("Unsupported QR version");
|
|
208
|
+
if (!parsed.pid || !parsed.spk || !parsed.pt || !parsed.url) throw new Error("Invalid QR data");
|
|
209
|
+
return parsed;
|
|
210
|
+
}
|
|
211
|
+
async function completePairing(qr, deviceName, platform, phoneNumber) {
|
|
212
|
+
console.log("[Pairing] Starting pairing...");
|
|
213
|
+
console.log("[Pairing] QR url:", qr.url);
|
|
214
|
+
const keyPair = await generateX25519KeyPair();
|
|
215
|
+
console.log("[Pairing] Generated keypair, publicKey length:", keyPair.publicKey.length);
|
|
216
|
+
await storeDevicePrivateKey(keyPair.privateKey);
|
|
217
|
+
console.log("[Pairing] Deriving shared key from server public key:", qr.spk.substring(0, 20) + "...");
|
|
218
|
+
const sharedKey = await deriveSharedKey(keyPair.privateKey, qr.spk);
|
|
219
|
+
console.log("[Pairing] Shared key derived, length:", sharedKey.length);
|
|
220
|
+
const wsUrl = new URL(qr.url);
|
|
221
|
+
const apiBase = `${wsUrl.protocol === "wss:" ? "https" : "http"}://${wsUrl.host}`;
|
|
222
|
+
const endpoint = `${apiBase}/api/v1/phone-relay/pair/complete`;
|
|
223
|
+
console.log("[Pairing] POST to:", endpoint);
|
|
224
|
+
const body = {
|
|
225
|
+
pairingId: qr.pid,
|
|
226
|
+
pairingToken: qr.pt,
|
|
227
|
+
devicePublicKey: keyPair.publicKey,
|
|
228
|
+
deviceName,
|
|
229
|
+
platform,
|
|
230
|
+
phoneNumber
|
|
231
|
+
};
|
|
232
|
+
const response = await fetch(endpoint, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: { "Content-Type": "application/json" },
|
|
235
|
+
body: JSON.stringify(body)
|
|
236
|
+
});
|
|
237
|
+
console.log("[Pairing] Response status:", response.status);
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
const errorText = await response.text();
|
|
240
|
+
console.error("[Pairing] Error response:", errorText);
|
|
241
|
+
let errorMsg = "Pairing failed";
|
|
242
|
+
try {
|
|
243
|
+
const errorJson = JSON.parse(errorText);
|
|
244
|
+
errorMsg = errorJson?.error?.message || errorJson?.message || errorMsg;
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`${errorMsg} (HTTP ${response.status})`);
|
|
248
|
+
}
|
|
249
|
+
const result = await response.json();
|
|
250
|
+
console.log("[Pairing] Success, relayId:", result?.data?.relayId);
|
|
251
|
+
await storeCredentials({
|
|
252
|
+
relayId: result.data.relayId,
|
|
253
|
+
authToken: result.data.authToken,
|
|
254
|
+
sharedKey,
|
|
255
|
+
serverUrl: qr.url
|
|
256
|
+
});
|
|
257
|
+
return result.data;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/client/sms-sender.ts
|
|
261
|
+
import { registerPlugin } from "@capacitor/core";
|
|
262
|
+
var Sms = registerPlugin("Sms");
|
|
263
|
+
async function requestSmsPermission() {
|
|
264
|
+
try {
|
|
265
|
+
const check = await Sms.checkPermissions();
|
|
266
|
+
if (check.sms === "granted") return true;
|
|
267
|
+
const result = await Sms.requestPermissions();
|
|
268
|
+
return result.sms === "granted";
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.error("SMS permission request failed:", err);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function sendNativeSms(to, body) {
|
|
275
|
+
try {
|
|
276
|
+
const result = await Sms.send({ to, body });
|
|
277
|
+
return result.sent;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
console.error("Native SMS send failed:", err);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
ReconnectingWebSocket,
|
|
285
|
+
clearAllData,
|
|
286
|
+
completePairing,
|
|
287
|
+
decryptE2E,
|
|
288
|
+
deriveSharedKey,
|
|
289
|
+
encryptE2E,
|
|
290
|
+
generateX25519KeyPair,
|
|
291
|
+
getDevicePrivateKey,
|
|
292
|
+
getStoredCredentials,
|
|
293
|
+
parseQrPayload,
|
|
294
|
+
requestSmsPermission,
|
|
295
|
+
sendNativeSms,
|
|
296
|
+
storeCredentials,
|
|
297
|
+
storeDevicePrivateKey
|
|
298
|
+
};
|