@peers-app/peers-sdk 0.20.5 → 0.21.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/dist/data/devices.d.ts +4 -4
- package/dist/data/voice-messages.d.ts +2 -2
- package/dist/device/binary-peer-connection-v2.d.ts +19 -2
- package/dist/device/binary-peer-connection-v2.js +127 -20
- package/dist/device/binary-peer-connection-v2.test.js +60 -0
- package/dist/device/binary-peer-connection.d.ts +13 -2
- package/dist/device/binary-peer-connection.js +77 -14
- package/dist/device/binary-peer-connection.test.js +21 -4
- package/dist/device/connection.d.ts +1 -1
- package/dist/device/device.d.ts +1 -1
- package/dist/device-pairing/constants.d.ts +34 -0
- package/dist/device-pairing/constants.js +37 -0
- package/dist/device-pairing/crypto.d.ts +45 -0
- package/dist/device-pairing/crypto.js +135 -0
- package/dist/device-pairing/device-pairing.test.d.ts +1 -0
- package/dist/device-pairing/device-pairing.test.js +174 -0
- package/dist/device-pairing/index.d.ts +6 -0
- package/dist/device-pairing/index.js +22 -0
- package/dist/device-pairing/invitation.d.ts +13 -0
- package/dist/device-pairing/invitation.js +85 -0
- package/dist/device-pairing/signaling.d.ts +9 -0
- package/dist/device-pairing/signaling.js +29 -0
- package/dist/device-pairing/transcript.d.ts +7 -0
- package/dist/device-pairing/transcript.js +38 -0
- package/dist/device-pairing/types.d.ts +1023 -0
- package/dist/device-pairing/types.js +304 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/rpc-types.d.ts +8 -0
- package/dist/rpc-types.js +7 -0
- package/dist/types/peer-device.d.ts +38 -1
- package/dist/types/peer-device.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const device_1 = require("../device/device");
|
|
4
|
+
const keys_1 = require("../keys");
|
|
5
|
+
const utils_1 = require("../utils");
|
|
6
|
+
const _1 = require(".");
|
|
7
|
+
const NOW = 1_800_000_000_000;
|
|
8
|
+
const FINGERPRINT = Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0"))
|
|
9
|
+
.join(":")
|
|
10
|
+
.toUpperCase();
|
|
11
|
+
describe("device pairing invitation", () => {
|
|
12
|
+
it("round-trips a versioned token and fragment URL", () => {
|
|
13
|
+
const invitation = (0, _1.createDevicePairingInvitation)({
|
|
14
|
+
serviceOrigin: "https://peers.app/path",
|
|
15
|
+
now: NOW,
|
|
16
|
+
});
|
|
17
|
+
const token = (0, _1.encodeDevicePairingInvitation)(invitation);
|
|
18
|
+
const url = (0, _1.createDevicePairingInvitationUrl)("https://app.peers.test/setup", invitation);
|
|
19
|
+
expect((0, _1.parseDevicePairingInvitation)(token, NOW)).toEqual(invitation);
|
|
20
|
+
expect((0, _1.parseDevicePairingInvitation)(url, NOW)).toEqual(invitation);
|
|
21
|
+
expect(invitation.roomId).toHaveLength(22);
|
|
22
|
+
expect(invitation.roomSecret).toHaveLength(43);
|
|
23
|
+
expect(invitation.serviceOrigin).toBe("https://peers.app");
|
|
24
|
+
});
|
|
25
|
+
it("rejects malformed, expired, unknown-version, and overlong invitations", () => {
|
|
26
|
+
const invitation = (0, _1.createDevicePairingInvitation)({
|
|
27
|
+
serviceOrigin: "https://peers.app",
|
|
28
|
+
now: NOW,
|
|
29
|
+
});
|
|
30
|
+
expect(() => (0, _1.parseDevicePairingInvitation)("not-an-invitation", NOW)).toThrow();
|
|
31
|
+
expect(() => (0, _1.parseDevicePairingInvitation)((0, _1.encodeDevicePairingInvitation)(invitation), NOW + 300_001)).toThrow();
|
|
32
|
+
expect(() => (0, _1.parseDevicePairingInvitation)((0, _1.encodeDevicePairingInvitation)({ ...invitation, version: 2 }), NOW)).toThrow();
|
|
33
|
+
expect(() => (0, _1.createDevicePairingInvitation)({
|
|
34
|
+
serviceOrigin: "https://peers.app",
|
|
35
|
+
now: NOW,
|
|
36
|
+
ttlMs: 300_001,
|
|
37
|
+
})).toThrow();
|
|
38
|
+
});
|
|
39
|
+
it("accepts only the configured bounded future clock skew", () => {
|
|
40
|
+
const skewedInvitation = (0, _1.createDevicePairingInvitation)({
|
|
41
|
+
serviceOrigin: "https://peers.app",
|
|
42
|
+
now: NOW + _1.DEVICE_PAIRING_CLOCK_SKEW_MS,
|
|
43
|
+
});
|
|
44
|
+
expect((0, _1.parseDevicePairingInvitation)((0, _1.encodeDevicePairingInvitation)(skewedInvitation), NOW)).toEqual(skewedInvitation);
|
|
45
|
+
expect(() => (0, _1.parseDevicePairingInvitation)((0, _1.encodeDevicePairingInvitation)({
|
|
46
|
+
...skewedInvitation,
|
|
47
|
+
expiresAt: skewedInvitation.expiresAt + 1,
|
|
48
|
+
}), NOW)).toThrow();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe("device pairing signaling", () => {
|
|
52
|
+
it("authenticates signaling and rejects modification, role substitution, and replay", () => {
|
|
53
|
+
const invitation = (0, _1.createDevicePairingInvitation)({
|
|
54
|
+
serviceOrigin: "https://peers.app",
|
|
55
|
+
now: NOW,
|
|
56
|
+
});
|
|
57
|
+
const signalingKey = (0, _1.derivePairingSignalingKey)(invitation.roomSecret);
|
|
58
|
+
const offerSdp = `v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`;
|
|
59
|
+
const signal = (0, _1.signPairingSignal)({
|
|
60
|
+
version: 1,
|
|
61
|
+
roomId: invitation.roomId,
|
|
62
|
+
senderRole: "source",
|
|
63
|
+
sequence: 1,
|
|
64
|
+
kind: "offer",
|
|
65
|
+
payload: {
|
|
66
|
+
type: "offer",
|
|
67
|
+
sdp: offerSdp,
|
|
68
|
+
endpointNonce: (0, keys_1.newToken)(32),
|
|
69
|
+
},
|
|
70
|
+
}, signalingKey);
|
|
71
|
+
expect((0, _1.verifyPairingSignal)(signal, signalingKey, {
|
|
72
|
+
roomId: invitation.roomId,
|
|
73
|
+
senderRole: "source",
|
|
74
|
+
nextSequence: 1,
|
|
75
|
+
})).toEqual(signal);
|
|
76
|
+
expect(() => (0, _1.verifyPairingSignal)({ ...signal, payload: { ...signal.payload, sdp: `${offerSdp}modified` } }, signalingKey, {
|
|
77
|
+
roomId: invitation.roomId,
|
|
78
|
+
senderRole: "source",
|
|
79
|
+
nextSequence: 1,
|
|
80
|
+
})).toThrow();
|
|
81
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, signalingKey, {
|
|
82
|
+
roomId: invitation.roomId,
|
|
83
|
+
senderRole: "destination",
|
|
84
|
+
nextSequence: 1,
|
|
85
|
+
})).toThrow();
|
|
86
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, signalingKey, {
|
|
87
|
+
roomId: invitation.roomId,
|
|
88
|
+
senderRole: "source",
|
|
89
|
+
nextSequence: 2,
|
|
90
|
+
})).toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("device pairing transcript and credentials", () => {
|
|
94
|
+
it("derives a stable transcript SAS that changes with SDP", () => {
|
|
95
|
+
const invitation = (0, _1.createDevicePairingInvitation)({
|
|
96
|
+
serviceOrigin: "https://peers.app",
|
|
97
|
+
now: NOW,
|
|
98
|
+
});
|
|
99
|
+
const source = new device_1.Device();
|
|
100
|
+
const destination = new device_1.Device();
|
|
101
|
+
const transcript = {
|
|
102
|
+
version: 1,
|
|
103
|
+
roomId: invitation.roomId,
|
|
104
|
+
sourceIdentity: (0, keys_1.openSignedObject)(source.getDeviceInfo()),
|
|
105
|
+
destinationIdentity: (0, keys_1.openSignedObject)(destination.getDeviceInfo()),
|
|
106
|
+
sourceEndpointNonce: (0, keys_1.newToken)(32),
|
|
107
|
+
destinationEndpointNonce: (0, keys_1.newToken)(32),
|
|
108
|
+
offerFingerprint: (0, _1.extractSha256SdpFingerprint)(`v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`),
|
|
109
|
+
answerFingerprint: (0, _1.extractSha256SdpFingerprint)(`v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`),
|
|
110
|
+
};
|
|
111
|
+
const hash = (0, _1.computeDevicePairingTranscriptHash)(transcript);
|
|
112
|
+
const sas = (0, _1.deriveDevicePairingSas)(invitation.roomSecret, hash);
|
|
113
|
+
const changedHash = (0, _1.computeDevicePairingTranscriptHash)({
|
|
114
|
+
...transcript,
|
|
115
|
+
answerFingerprint: FINGERPRINT.replace("00", "FF"),
|
|
116
|
+
});
|
|
117
|
+
expect(sas).toMatch(/^\d{6}$/);
|
|
118
|
+
expect((0, _1.deriveDevicePairingSas)(invitation.roomSecret, hash)).toBe(sas);
|
|
119
|
+
expect((0, _1.deriveDevicePairingSas)(invitation.roomSecret, changedHash)).not.toBe(sas);
|
|
120
|
+
});
|
|
121
|
+
it("requires the expected identities and transcript for approval, credentials, and receipt", () => {
|
|
122
|
+
const source = new device_1.Device();
|
|
123
|
+
const destination = new device_1.Device();
|
|
124
|
+
const transcriptHash = (0, keys_1.newToken)(32);
|
|
125
|
+
const expiresAt = NOW + 60_000;
|
|
126
|
+
const approval = (0, _1.createSignedPairingApproval)(destination, "destination", transcriptHash, expiresAt);
|
|
127
|
+
expect((0, _1.verifySignedPairingApproval)(approval, {
|
|
128
|
+
publicKey: destination.publicKey,
|
|
129
|
+
transcriptHash,
|
|
130
|
+
role: "destination",
|
|
131
|
+
now: NOW,
|
|
132
|
+
})).toHaveLength(43);
|
|
133
|
+
expect(() => (0, _1.verifySignedPairingApproval)(approval, {
|
|
134
|
+
publicKey: source.publicKey,
|
|
135
|
+
transcriptHash,
|
|
136
|
+
role: "destination",
|
|
137
|
+
now: NOW,
|
|
138
|
+
})).toThrow();
|
|
139
|
+
const credentials = { userId: (0, utils_1.newid)(), secretKey: (0, keys_1.newKeys)().secretKey };
|
|
140
|
+
const destinationInfo = (0, keys_1.openSignedObject)(destination.getDeviceInfo());
|
|
141
|
+
const envelope = (0, _1.createPairingCredentialEnvelope)(source, destinationInfo, credentials, transcriptHash, expiresAt);
|
|
142
|
+
expect((0, _1.openPairingCredentialEnvelope)(destination, envelope, {
|
|
143
|
+
sourcePublicKey: source.publicKey,
|
|
144
|
+
sourcePublicBoxKey: source.publicBoxKey,
|
|
145
|
+
transcriptHash,
|
|
146
|
+
now: NOW,
|
|
147
|
+
})).toEqual({
|
|
148
|
+
version: 1,
|
|
149
|
+
transcriptHash,
|
|
150
|
+
expiresAt,
|
|
151
|
+
...credentials,
|
|
152
|
+
});
|
|
153
|
+
expect(() => (0, _1.openPairingCredentialEnvelope)(new device_1.Device(), envelope, {
|
|
154
|
+
sourcePublicKey: source.publicKey,
|
|
155
|
+
sourcePublicBoxKey: source.publicBoxKey,
|
|
156
|
+
transcriptHash,
|
|
157
|
+
now: NOW,
|
|
158
|
+
})).toThrow();
|
|
159
|
+
const initialized = new device_1.Device(credentials.userId);
|
|
160
|
+
const initializedInfo = (0, keys_1.openSignedObject)(initialized.getDeviceInfo());
|
|
161
|
+
const receipt = (0, _1.createSignedPairingInstallationReceipt)(destination, transcriptHash, initializedInfo, NOW);
|
|
162
|
+
expect((0, _1.verifySignedPairingInstallationReceipt)(receipt, {
|
|
163
|
+
temporaryDestinationPublicKey: destination.publicKey,
|
|
164
|
+
transcriptHash,
|
|
165
|
+
})).toEqual(initializedInfo);
|
|
166
|
+
});
|
|
167
|
+
it("derives independent room-access and signaling credentials", () => {
|
|
168
|
+
const invitation = (0, _1.createDevicePairingInvitation)({
|
|
169
|
+
serviceOrigin: "https://peers.app",
|
|
170
|
+
now: NOW,
|
|
171
|
+
});
|
|
172
|
+
expect((0, _1.derivePairingRoomAccessToken)(invitation.roomSecret)).not.toBe((0, _1.derivePairingSignalingKey)(invitation.roomSecret));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./constants"), exports);
|
|
18
|
+
__exportStar(require("./crypto"), exports);
|
|
19
|
+
__exportStar(require("./invitation"), exports);
|
|
20
|
+
__exportStar(require("./signaling"), exports);
|
|
21
|
+
__exportStar(require("./transcript"), exports);
|
|
22
|
+
__exportStar(require("./types"), exports);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DevicePairingInvitation } from "./types";
|
|
2
|
+
/** Creates a five-minute, high-entropy invitation for a signaling service origin. */
|
|
3
|
+
export declare function createDevicePairingInvitation({ serviceOrigin, now, ttlMs, }: {
|
|
4
|
+
serviceOrigin: string;
|
|
5
|
+
now?: number;
|
|
6
|
+
ttlMs?: number;
|
|
7
|
+
}): DevicePairingInvitation;
|
|
8
|
+
/** Encodes an invitation as a versioned token suitable for a URL fragment or manual entry. */
|
|
9
|
+
export declare function encodeDevicePairingInvitation(invitation: DevicePairingInvitation): string;
|
|
10
|
+
/** Parses and validates a versioned invitation token or fragment URL. */
|
|
11
|
+
export declare function parseDevicePairingInvitation(input: string, now?: number): DevicePairingInvitation;
|
|
12
|
+
/** Places the invitation in a URL fragment so it is excluded from HTTP requests and access logs. */
|
|
13
|
+
export declare function createDevicePairingInvitationUrl(invitationBaseUrl: string, invitation: DevicePairingInvitation): string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createDevicePairingInvitation = createDevicePairingInvitation;
|
|
4
|
+
exports.encodeDevicePairingInvitation = encodeDevicePairingInvitation;
|
|
5
|
+
exports.parseDevicePairingInvitation = parseDevicePairingInvitation;
|
|
6
|
+
exports.createDevicePairingInvitationUrl = createDevicePairingInvitationUrl;
|
|
7
|
+
const keys_1 = require("../keys");
|
|
8
|
+
const constants_1 = require("./constants");
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
const INVITATION_TOKEN_PREFIX = "peers-pairing-v1.";
|
|
11
|
+
const textDecoder = new TextDecoder();
|
|
12
|
+
const textEncoder = new TextEncoder();
|
|
13
|
+
function normalizeServiceOrigin(value) {
|
|
14
|
+
const url = new URL(value);
|
|
15
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
16
|
+
throw new Error("Pairing service must use HTTP or HTTPS");
|
|
17
|
+
}
|
|
18
|
+
return url.origin;
|
|
19
|
+
}
|
|
20
|
+
function extractInvitationToken(input) {
|
|
21
|
+
const trimmed = input.trim();
|
|
22
|
+
if (trimmed.startsWith(INVITATION_TOKEN_PREFIX)) {
|
|
23
|
+
return trimmed;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const url = new URL(trimmed);
|
|
27
|
+
const fragment = url.hash.slice(1);
|
|
28
|
+
if (fragment.startsWith("pair=")) {
|
|
29
|
+
return decodeURIComponent(fragment.slice("pair=".length));
|
|
30
|
+
}
|
|
31
|
+
if (fragment.startsWith("pair?")) {
|
|
32
|
+
const token = new URLSearchParams(fragment.slice("pair?".length)).get("token");
|
|
33
|
+
if (token) {
|
|
34
|
+
return token;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// The generic error below deliberately avoids echoing invitation input.
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Invalid pairing invitation");
|
|
42
|
+
}
|
|
43
|
+
/** Creates a five-minute, high-entropy invitation for a signaling service origin. */
|
|
44
|
+
function createDevicePairingInvitation({ serviceOrigin, now = Date.now(), ttlMs = constants_1.DEVICE_PAIRING_TTL_MS, }) {
|
|
45
|
+
if (!Number.isSafeInteger(now) || ttlMs <= 0 || ttlMs > constants_1.DEVICE_PAIRING_TTL_MS) {
|
|
46
|
+
throw new Error("Invalid pairing invitation lifetime");
|
|
47
|
+
}
|
|
48
|
+
return types_1.devicePairingInvitationSchema.parse({
|
|
49
|
+
version: constants_1.DEVICE_PAIRING_PROTOCOL_VERSION,
|
|
50
|
+
roomId: (0, keys_1.newToken)(16),
|
|
51
|
+
roomSecret: (0, keys_1.newToken)(32),
|
|
52
|
+
expiresAt: now + ttlMs,
|
|
53
|
+
serviceOrigin: normalizeServiceOrigin(serviceOrigin),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Encodes an invitation as a versioned token suitable for a URL fragment or manual entry. */
|
|
57
|
+
function encodeDevicePairingInvitation(invitation) {
|
|
58
|
+
const parsed = types_1.devicePairingInvitationSchema.parse(invitation);
|
|
59
|
+
return `${INVITATION_TOKEN_PREFIX}${(0, keys_1.encodeBase64)(textEncoder.encode(JSON.stringify(parsed)))}`;
|
|
60
|
+
}
|
|
61
|
+
/** Parses and validates a versioned invitation token or fragment URL. */
|
|
62
|
+
function parseDevicePairingInvitation(input, now = Date.now()) {
|
|
63
|
+
try {
|
|
64
|
+
const token = extractInvitationToken(input);
|
|
65
|
+
if (!token.startsWith(INVITATION_TOKEN_PREFIX)) {
|
|
66
|
+
throw new Error("unsupported");
|
|
67
|
+
}
|
|
68
|
+
const encoded = token.slice(INVITATION_TOKEN_PREFIX.length);
|
|
69
|
+
const parsed = types_1.devicePairingInvitationSchema.parse(JSON.parse(textDecoder.decode((0, keys_1.decodeBase64)(encoded))));
|
|
70
|
+
if (parsed.expiresAt <= now ||
|
|
71
|
+
parsed.expiresAt > now + constants_1.DEVICE_PAIRING_TTL_MS + constants_1.DEVICE_PAIRING_CLOCK_SKEW_MS) {
|
|
72
|
+
throw new Error("expired");
|
|
73
|
+
}
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new Error("Invalid or expired pairing invitation");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Places the invitation in a URL fragment so it is excluded from HTTP requests and access logs. */
|
|
81
|
+
function createDevicePairingInvitationUrl(invitationBaseUrl, invitation) {
|
|
82
|
+
const url = new URL(invitationBaseUrl);
|
|
83
|
+
url.hash = `pair=${encodeURIComponent(encodeDevicePairingInvitation(invitation))}`;
|
|
84
|
+
return url.toString();
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PairingRole, type PairingSignal, type UnsignedPairingSignal } from "./types";
|
|
2
|
+
/** Attaches an HMAC to a validated canonical SDP or ICE signaling message. */
|
|
3
|
+
export declare function signPairingSignal(signal: UnsignedPairingSignal, signalingKey: string): PairingSignal;
|
|
4
|
+
/** Verifies message authentication, room/role binding, and the exact next sequence number. */
|
|
5
|
+
export declare function verifyPairingSignal(signal: unknown, signalingKey: string, expected: {
|
|
6
|
+
roomId: string;
|
|
7
|
+
senderRole: PairingRole;
|
|
8
|
+
nextSequence: number;
|
|
9
|
+
}): PairingSignal;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signPairingSignal = signPairingSignal;
|
|
4
|
+
exports.verifyPairingSignal = verifyPairingSignal;
|
|
5
|
+
const crypto_1 = require("./crypto");
|
|
6
|
+
const types_1 = require("./types");
|
|
7
|
+
/** Attaches an HMAC to a validated canonical SDP or ICE signaling message. */
|
|
8
|
+
function signPairingSignal(signal, signalingKey) {
|
|
9
|
+
const parsed = types_1.unsignedPairingSignalSchema.parse(signal);
|
|
10
|
+
return types_1.pairingSignalSchema.parse({
|
|
11
|
+
...parsed,
|
|
12
|
+
mac: (0, crypto_1.computePairingSignalMac)(parsed, signalingKey),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Verifies message authentication, room/role binding, and the exact next sequence number. */
|
|
16
|
+
function verifyPairingSignal(signal, signalingKey, expected) {
|
|
17
|
+
const parsed = types_1.pairingSignalSchema.parse(signal);
|
|
18
|
+
const { mac, ...unsignedValue } = parsed;
|
|
19
|
+
const unsigned = types_1.unsignedPairingSignalSchema.parse(unsignedValue);
|
|
20
|
+
if (!(0, crypto_1.isPairingSignalMacValid)(unsigned, mac, signalingKey)) {
|
|
21
|
+
throw new Error("Pairing signaling authentication failed");
|
|
22
|
+
}
|
|
23
|
+
if (parsed.roomId !== expected.roomId ||
|
|
24
|
+
parsed.senderRole !== expected.senderRole ||
|
|
25
|
+
parsed.sequence !== expected.nextSequence) {
|
|
26
|
+
throw new Error("Pairing signaling message is out of order");
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DevicePairingTranscript } from "./types";
|
|
2
|
+
/** Extracts a canonical set of SHA-256 DTLS fingerprints from an SDP document. */
|
|
3
|
+
export declare function extractSha256SdpFingerprint(sdp: string): string;
|
|
4
|
+
/** Hashes the canonical identities, nonces, room, and SDP fingerprints for one ceremony. */
|
|
5
|
+
export declare function computeDevicePairingTranscriptHash(transcript: DevicePairingTranscript): string;
|
|
6
|
+
/** Derives the same zero-padded six-digit SAS independently on both endpoints. */
|
|
7
|
+
export declare function deriveDevicePairingSas(roomSecret: string, transcriptHash: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractSha256SdpFingerprint = extractSha256SdpFingerprint;
|
|
4
|
+
exports.computeDevicePairingTranscriptHash = computeDevicePairingTranscriptHash;
|
|
5
|
+
exports.deriveDevicePairingSas = deriveDevicePairingSas;
|
|
6
|
+
const hmac_1 = require("@noble/hashes/hmac");
|
|
7
|
+
const sha2_1 = require("@noble/hashes/sha2");
|
|
8
|
+
const keys_1 = require("../keys");
|
|
9
|
+
const constants_1 = require("./constants");
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
const textEncoder = new TextEncoder();
|
|
12
|
+
/** Extracts a canonical set of SHA-256 DTLS fingerprints from an SDP document. */
|
|
13
|
+
function extractSha256SdpFingerprint(sdp) {
|
|
14
|
+
const fingerprints = new Set();
|
|
15
|
+
const expression = /^a=fingerprint:sha-256\s+([0-9a-f:]+)\s*$/gim;
|
|
16
|
+
for (const match of sdp.matchAll(expression)) {
|
|
17
|
+
const fingerprint = match[1].toUpperCase();
|
|
18
|
+
const bytes = fingerprint.split(":");
|
|
19
|
+
if (bytes.length === 32 && bytes.every((part) => /^[0-9A-F]{2}$/.test(part))) {
|
|
20
|
+
fingerprints.add(fingerprint);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (fingerprints.size === 0) {
|
|
24
|
+
throw new Error("WebRTC description did not contain a SHA-256 fingerprint");
|
|
25
|
+
}
|
|
26
|
+
return [...fingerprints].sort().join(",");
|
|
27
|
+
}
|
|
28
|
+
/** Hashes the canonical identities, nonces, room, and SDP fingerprints for one ceremony. */
|
|
29
|
+
function computeDevicePairingTranscriptHash(transcript) {
|
|
30
|
+
return types_1.pairingHashSchema.parse((0, keys_1.hashObject)(types_1.devicePairingTranscriptSchema.parse(transcript)));
|
|
31
|
+
}
|
|
32
|
+
/** Derives the same zero-padded six-digit SAS independently on both endpoints. */
|
|
33
|
+
function deriveDevicePairingSas(roomSecret, transcriptHash) {
|
|
34
|
+
types_1.pairingHashSchema.parse(transcriptHash);
|
|
35
|
+
const digest = (0, hmac_1.hmac)(sha2_1.sha256, (0, keys_1.decodeBase64)(roomSecret), textEncoder.encode(`${constants_1.DEVICE_PAIRING_SAS_LABEL}${transcriptHash}`));
|
|
36
|
+
const value = (((digest[0] << 24) >>> 0) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
|
|
37
|
+
return (value % 1_000_000).toString().padStart(6, "0");
|
|
38
|
+
}
|