@peers-app/peers-sdk 0.23.14 → 0.25.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/context/user-context.d.ts +8 -0
- package/dist/context/user-context.js +9 -1
- package/dist/contracts/index.d.ts +1 -0
- package/dist/contracts/index.js +4 -1
- package/dist/contracts/system/index.d.ts +2 -0
- package/dist/contracts/system/index.js +5 -1
- package/dist/contracts/system/invites-contract.d.ts +121 -0
- package/dist/contracts/system/invites-contract.js +150 -0
- package/dist/data/create-group.d.ts +24 -0
- package/dist/data/create-group.js +61 -0
- package/dist/data/group-secret.d.ts +10 -0
- package/dist/data/group-secret.js +26 -0
- package/dist/data/group-share.d.ts +0 -20
- package/dist/data/group-share.js +0 -35
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.js +2 -0
- package/dist/data/invites.d.ts +350 -0
- package/dist/data/invites.js +99 -0
- package/dist/data/orm/data-query.d.ts +1 -0
- package/dist/data/orm/sql.data-source.d.ts +3 -2
- package/dist/data/orm/sql.data-source.js +5 -3
- package/dist/data/orm/subscribable.data-source.d.ts +3 -2
- package/dist/data/orm/subscribable.data-source.js +7 -11
- package/dist/data/users.d.ts +11 -0
- package/dist/data/users.js +17 -0
- package/dist/device/device-election.d.ts +23 -3
- package/dist/device/device-election.js +15 -0
- package/dist/device/device-election.test.js +73 -0
- package/dist/device-pairing/constants.d.ts +27 -9
- package/dist/device-pairing/constants.js +28 -10
- package/dist/device-pairing/cpace.d.ts +46 -0
- package/dist/device-pairing/cpace.js +173 -0
- package/dist/device-pairing/cpace.test.js +116 -0
- package/dist/device-pairing/crypto.d.ts +40 -15
- package/dist/device-pairing/crypto.js +74 -33
- package/dist/device-pairing/device-pairing.test.js +156 -119
- package/dist/device-pairing/index.d.ts +3 -1
- package/dist/device-pairing/index.js +3 -1
- package/dist/device-pairing/pairing-code.d.ts +27 -0
- package/dist/device-pairing/pairing-code.js +84 -0
- package/dist/device-pairing/pgp-word-list.d.ts +14 -0
- package/dist/device-pairing/pgp-word-list.js +531 -0
- package/dist/device-pairing/signaling.d.ts +17 -6
- package/dist/device-pairing/signaling.js +31 -8
- package/dist/device-pairing/transcript.d.ts +3 -3
- package/dist/device-pairing/transcript.js +6 -13
- package/dist/device-pairing/types.d.ts +870 -224
- package/dist/device-pairing/types.js +186 -108
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/invites/data-box-schema.d.ts +15 -0
- package/dist/invites/data-box-schema.js +15 -0
- package/dist/invites/group-approval.d.ts +43 -0
- package/dist/invites/group-approval.js +155 -0
- package/dist/invites/group-approval.test.js +221 -0
- package/dist/invites/index.d.ts +5 -0
- package/dist/{group-invite → invites}/index.js +5 -3
- package/dist/invites/invite-epoch.d.ts +18 -0
- package/dist/invites/invite-epoch.js +35 -0
- package/dist/invites/invite-messages.d.ts +1354 -0
- package/dist/invites/invite-messages.js +145 -0
- package/dist/invites/invite-token.d.ts +334 -0
- package/dist/invites/invite-token.js +178 -0
- package/dist/invites/invite-token.test.d.ts +1 -0
- package/dist/invites/invite-token.test.js +123 -0
- package/dist/keys.d.ts +17 -1
- package/dist/keys.js +58 -5
- package/dist/keys.test.js +113 -0
- package/dist/rpc-types.d.ts +5 -4
- package/dist/rpc-types.js +2 -1
- package/dist/serial-json.d.ts +4 -1
- package/dist/serial-json.js +4 -1
- package/dist/system-ids.d.ts +22 -0
- package/dist/system-ids.js +23 -1
- package/package.json +1 -1
- package/dist/device-pairing/invitation.d.ts +0 -13
- package/dist/device-pairing/invitation.js +0 -85
- package/dist/group-invite/group-invite.d.ts +0 -53
- package/dist/group-invite/group-invite.js +0 -231
- package/dist/group-invite/group-invite.pvars.d.ts +0 -94
- package/dist/group-invite/group-invite.pvars.js +0 -139
- package/dist/group-invite/group-invite.types.d.ts +0 -124
- package/dist/group-invite/group-invite.types.js +0 -8
- package/dist/group-invite/index.d.ts +0 -3
- package/dist/user-connect/connection-code.d.ts +0 -67
- package/dist/user-connect/connection-code.js +0 -176
- package/dist/user-connect/connection-code.test.js +0 -213
- package/dist/user-connect/index.d.ts +0 -3
- package/dist/user-connect/index.js +0 -19
- package/dist/user-connect/user-connect.pvars.d.ts +0 -20
- package/dist/user-connect/user-connect.pvars.js +0 -63
- package/dist/user-connect/user-connect.pvars.test.js +0 -46
- package/dist/user-connect/user-connect.types.d.ts +0 -94
- package/dist/user-connect/user-connect.types.js +0 -68
- /package/dist/{user-connect/connection-code.test.d.ts → device-pairing/cpace.test.d.ts} +0 -0
- /package/dist/{user-connect/user-connect.pvars.test.d.ts → invites/group-approval.test.d.ts} +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
4
|
-
exports.
|
|
5
|
-
exports.
|
|
3
|
+
exports.startPairingPake = startPairingPake;
|
|
4
|
+
exports.derivePairingSessionKeys = derivePairingSessionKeys;
|
|
5
|
+
exports.derivePairingWebSocketToken = derivePairingWebSocketToken;
|
|
6
6
|
exports.computePairingSignalMac = computePairingSignalMac;
|
|
7
7
|
exports.isPairingSignalMacValid = isPairingSignalMacValid;
|
|
8
|
-
exports.
|
|
9
|
-
exports.
|
|
8
|
+
exports.createPairingConfirmation = createPairingConfirmation;
|
|
9
|
+
exports.verifyPairingConfirmation = verifyPairingConfirmation;
|
|
10
10
|
exports.createPairingCredentialEnvelope = createPairingCredentialEnvelope;
|
|
11
11
|
exports.openPairingCredentialEnvelope = openPairingCredentialEnvelope;
|
|
12
12
|
exports.createSignedPairingInstallationReceipt = createSignedPairingInstallationReceipt;
|
|
@@ -15,6 +15,8 @@ const hmac_1 = require("@noble/hashes/hmac");
|
|
|
15
15
|
const sha2_1 = require("@noble/hashes/sha2");
|
|
16
16
|
const keys_1 = require("../keys");
|
|
17
17
|
const constants_1 = require("./constants");
|
|
18
|
+
const cpace_1 = require("./cpace");
|
|
19
|
+
const pairing_code_1 = require("./pairing-code");
|
|
18
20
|
const types_1 = require("./types");
|
|
19
21
|
const textEncoder = new TextEncoder();
|
|
20
22
|
function computeHmac(key, value) {
|
|
@@ -30,17 +32,42 @@ function equalBytes(left, right) {
|
|
|
30
32
|
}
|
|
31
33
|
return difference === 0;
|
|
32
34
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Starts CPace for one role. The source is the initiator and the destination the responder;
|
|
37
|
+
* the exchange is bound to the service origin and nameplate so a relayed share from another
|
|
38
|
+
* room cannot be replayed.
|
|
39
|
+
*/
|
|
40
|
+
function startPairingPake(role, code, serviceOrigin) {
|
|
41
|
+
const session = (0, cpace_1.cpaceStart)(role === "source" ? "initiator" : "responder", {
|
|
42
|
+
password: (0, pairing_code_1.pairingPasswordFromCode)(code),
|
|
43
|
+
channelId: (0, pairing_code_1.pairingChannelId)(serviceOrigin, code.nameplate),
|
|
44
|
+
initiatorAd: textEncoder.encode("source"),
|
|
45
|
+
responderAd: textEncoder.encode("destination"),
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
share: (0, keys_1.encodeBase64)(session.share),
|
|
49
|
+
finish(remoteShare) {
|
|
50
|
+
const isk = session.finish((0, keys_1.decodeBase64)(remoteShare));
|
|
51
|
+
const keys = derivePairingSessionKeys(isk);
|
|
52
|
+
isk.fill(0);
|
|
53
|
+
return keys;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
36
56
|
}
|
|
37
|
-
/**
|
|
38
|
-
function
|
|
39
|
-
return
|
|
57
|
+
/** Derives independent signaling and confirmation keys from a PAKE intermediate session key. */
|
|
58
|
+
function derivePairingSessionKeys(isk) {
|
|
59
|
+
return {
|
|
60
|
+
signalingKey: (0, keys_1.encodeBase64)(computeHmac(isk, constants_1.DEVICE_PAIRING_SIGNALING_LABEL)),
|
|
61
|
+
confirmKey: (0, keys_1.encodeBase64)(computeHmac(isk, constants_1.DEVICE_PAIRING_CONFIRM_LABEL)),
|
|
62
|
+
};
|
|
40
63
|
}
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Derives the token a source presents (`auth.token`) when it connects to the destination's direct
|
|
66
|
+
* WebSocket pairing listener. Both endpoints compute it from the signaling key, so it never
|
|
67
|
+
* travels through the rendezvous; a socket without it is refused before any Connection exists.
|
|
68
|
+
*/
|
|
69
|
+
function derivePairingWebSocketToken(signalingKey) {
|
|
70
|
+
return (0, keys_1.encodeBase64)(computeHmac((0, keys_1.decodeBase64)(signalingKey), constants_1.DEVICE_PAIRING_WEBSOCKET_TOKEN_LABEL));
|
|
44
71
|
}
|
|
45
72
|
/** Computes the MAC over the canonical unsigned signaling envelope. */
|
|
46
73
|
function computePairingSignalMac(signal, signalingKey) {
|
|
@@ -56,31 +83,45 @@ function isPairingSignalMacValid(signal, mac, signalingKey) {
|
|
|
56
83
|
return false;
|
|
57
84
|
}
|
|
58
85
|
}
|
|
59
|
-
/**
|
|
60
|
-
function
|
|
61
|
-
const
|
|
86
|
+
/** Builds the local `pairing.confirm` message authenticated with the PAKE confirmation key. */
|
|
87
|
+
function createPairingConfirmation(contents, confirmKey) {
|
|
88
|
+
const parsed = types_1.pairingConfirmationContentsSchema.parse({
|
|
62
89
|
version: constants_1.DEVICE_PAIRING_PROTOCOL_VERSION,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
...contents,
|
|
91
|
+
});
|
|
92
|
+
return types_1.pairingConfirmationSchema.parse({
|
|
93
|
+
...parsed,
|
|
94
|
+
mac: (0, keys_1.encodeBase64)(computeHmac((0, keys_1.decodeBase64)(confirmKey), (0, keys_1.stableStringify)(parsed))),
|
|
67
95
|
});
|
|
68
|
-
return types_1.signedPairingApprovalSchema.parse(device.signObjectWithSecretKey(contents));
|
|
69
96
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Verifies the remote confirmation's MAC, role, transcript, and identity binding.
|
|
99
|
+
* A MAC failure means the other endpoint used a different code or saw a different transcript.
|
|
100
|
+
*/
|
|
101
|
+
function verifyPairingConfirmation(value, confirmKey, expected) {
|
|
102
|
+
const parsed = types_1.pairingConfirmationSchema.parse(value);
|
|
103
|
+
const { mac, ...contents } = parsed;
|
|
104
|
+
const expectedMac = computeHmac((0, keys_1.decodeBase64)(confirmKey), (0, keys_1.stableStringify)(contents));
|
|
105
|
+
let macValid = false;
|
|
106
|
+
try {
|
|
107
|
+
macValid = equalBytes(expectedMac, (0, keys_1.decodeBase64)(mac));
|
|
75
108
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
throw new Error("Pairing approval does not match this ceremony");
|
|
109
|
+
catch {
|
|
110
|
+
macValid = false;
|
|
79
111
|
}
|
|
80
|
-
if (
|
|
81
|
-
throw new Error("Pairing
|
|
112
|
+
if (!macValid) {
|
|
113
|
+
throw new Error("Pairing code did not match");
|
|
114
|
+
}
|
|
115
|
+
if (contents.role !== expected.role || contents.transcriptHash !== expected.transcriptHash) {
|
|
116
|
+
throw new Error("Pairing confirmation does not match this ceremony");
|
|
117
|
+
}
|
|
118
|
+
if (contents.device.userId !== expected.device.userId ||
|
|
119
|
+
contents.device.deviceId !== expected.device.deviceId ||
|
|
120
|
+
contents.device.publicKey !== expected.device.publicKey ||
|
|
121
|
+
contents.device.publicBoxKey !== expected.device.publicBoxKey) {
|
|
122
|
+
throw new Error("Pairing confirmation identity mismatch");
|
|
82
123
|
}
|
|
83
|
-
return
|
|
124
|
+
return parsed;
|
|
84
125
|
}
|
|
85
126
|
/** Signs and encrypts account credentials directly to the temporary destination identity. */
|
|
86
127
|
function createPairingCredentialEnvelope(sourceDevice, destinationDevice, credentials, transcriptHash, expiresAt) {
|
|
@@ -5,137 +5,186 @@ const keys_1 = require("../keys");
|
|
|
5
5
|
const utils_1 = require("../utils");
|
|
6
6
|
const _1 = require(".");
|
|
7
7
|
const NOW = 1_800_000_000_000;
|
|
8
|
+
const ORIGIN = "https://peers.app";
|
|
8
9
|
const FINGERPRINT = Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0"))
|
|
9
10
|
.join(":")
|
|
10
11
|
.toUpperCase();
|
|
11
|
-
describe("
|
|
12
|
-
it("
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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");
|
|
12
|
+
describe("pairing code", () => {
|
|
13
|
+
it("uses the full PGP word lists with no duplicates", () => {
|
|
14
|
+
expect(_1.PGP_EVEN_WORDS).toHaveLength(256);
|
|
15
|
+
expect(_1.PGP_ODD_WORDS).toHaveLength(256);
|
|
16
|
+
expect(new Set([..._1.PGP_EVEN_WORDS, ..._1.PGP_ODD_WORDS]).size).toBe(512);
|
|
24
17
|
});
|
|
25
|
-
it("
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
18
|
+
it("generates even/odd/even words and round-trips through formatting and parsing", () => {
|
|
19
|
+
const words = (0, _1.generatePairingWords)();
|
|
20
|
+
expect(_1.PGP_EVEN_WORDS).toContain(words[0]);
|
|
21
|
+
expect(_1.PGP_ODD_WORDS).toContain(words[1]);
|
|
22
|
+
expect(_1.PGP_EVEN_WORDS).toContain(words[2]);
|
|
23
|
+
const text = (0, _1.formatPairingCode)(7, words);
|
|
24
|
+
expect(text).toBe(`7-${words.join("-")}`);
|
|
25
|
+
expect((0, _1.parsePairingCode)(text)).toEqual({ nameplate: 7, words });
|
|
26
|
+
expect((0, _1.parsePairingCode)(` 7 ${words.join(" ").toUpperCase()} `)).toEqual({
|
|
27
|
+
nameplate: 7,
|
|
28
|
+
words,
|
|
29
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
30
|
});
|
|
39
|
-
it("
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
expect((
|
|
45
|
-
expect(() => (0, _1.
|
|
46
|
-
|
|
47
|
-
expiresAt: skewedInvitation.expiresAt + 1,
|
|
48
|
-
}), NOW)).toThrow();
|
|
31
|
+
it("rejects wrong slots, unknown words, bad nameplates, and wrong lengths", () => {
|
|
32
|
+
expect(() => (0, _1.parsePairingCode)("7-adroitness-aardvark-absurd")).toThrow(/word 1/);
|
|
33
|
+
expect(() => (0, _1.parsePairingCode)("7-aardvark-absurd-acme")).toThrow(/word 2/);
|
|
34
|
+
expect(() => (0, _1.parsePairingCode)("7-aardvark-adroitness-nope")).toThrow(/word 3/);
|
|
35
|
+
expect(() => (0, _1.parsePairingCode)("0-aardvark-adroitness-acme")).toThrow(/room number/);
|
|
36
|
+
expect(() => (0, _1.parsePairingCode)("10000-aardvark-adroitness-acme")).toThrow(/room number/);
|
|
37
|
+
expect(() => (0, _1.parsePairingCode)("aardvark-adroitness-acme")).toThrow(/three words/);
|
|
38
|
+
expect(() => (0, _1.parsePairingCode)("7-aardvark-adroitness")).toThrow(/three words/);
|
|
49
39
|
});
|
|
50
40
|
});
|
|
51
|
-
describe("
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
41
|
+
describe("pairing PAKE and signaling", () => {
|
|
42
|
+
const code = (0, _1.parsePairingCode)("42-aardvark-adroitness-acme");
|
|
43
|
+
it("derives matching keys for the same code and different keys for a wrong code", () => {
|
|
44
|
+
const source = (0, _1.startPairingPake)("source", code, ORIGIN);
|
|
45
|
+
const destination = (0, _1.startPairingPake)("destination", code, ORIGIN);
|
|
46
|
+
const sourceKeys = source.finish(destination.share);
|
|
47
|
+
const destinationKeys = destination.finish(source.share);
|
|
48
|
+
expect(sourceKeys).toEqual(destinationKeys);
|
|
49
|
+
expect(sourceKeys.signalingKey).not.toBe(sourceKeys.confirmKey);
|
|
50
|
+
const token = (0, _1.derivePairingWebSocketToken)(sourceKeys.signalingKey);
|
|
51
|
+
expect(token).toBe((0, _1.derivePairingWebSocketToken)(destinationKeys.signalingKey));
|
|
52
|
+
expect(token).not.toBe(sourceKeys.signalingKey);
|
|
53
|
+
expect(token).not.toBe(sourceKeys.confirmKey);
|
|
54
|
+
const wrong = (0, _1.startPairingPake)("source", (0, _1.parsePairingCode)("42-absurd-adroitness-acme"), ORIGIN);
|
|
55
|
+
const honest = (0, _1.startPairingPake)("destination", code, ORIGIN);
|
|
56
|
+
expect(wrong.finish(honest.share)).not.toEqual(honest.finish(wrong.share));
|
|
57
|
+
const otherRoom = (0, _1.startPairingPake)("source", { ...code, nameplate: 43 }, ORIGIN);
|
|
58
|
+
const thisRoom = (0, _1.startPairingPake)("destination", code, ORIGIN);
|
|
59
|
+
expect(otherRoom.finish(thisRoom.share)).not.toEqual(thisRoom.finish(otherRoom.share));
|
|
60
|
+
});
|
|
61
|
+
it("accepts the pake share unauthenticated and everything else only with the key", () => {
|
|
62
|
+
const share = (0, _1.startPairingPake)("source", code, ORIGIN).share;
|
|
63
|
+
const pake = (0, _1.createPairingPakeSignal)({
|
|
64
|
+
nameplate: 42,
|
|
62
65
|
senderRole: "source",
|
|
63
66
|
sequence: 1,
|
|
64
|
-
|
|
67
|
+
share,
|
|
68
|
+
});
|
|
69
|
+
expect((0, _1.verifyPairingSignal)(pake, undefined, {
|
|
70
|
+
nameplate: 42,
|
|
71
|
+
senderRole: "source",
|
|
72
|
+
nextSequence: 1,
|
|
73
|
+
})).toEqual(pake);
|
|
74
|
+
const keys = (0, _1.startPairingPake)("destination", code, ORIGIN);
|
|
75
|
+
const signalingKey = keys.finish(share).signalingKey;
|
|
76
|
+
const options = (0, _1.signPairingSignal)({
|
|
77
|
+
version: 3,
|
|
78
|
+
nameplate: 42,
|
|
79
|
+
senderRole: "destination",
|
|
80
|
+
sequence: 2,
|
|
81
|
+
kind: "transport-options",
|
|
65
82
|
payload: {
|
|
66
|
-
type: "offer",
|
|
67
|
-
sdp: offerSdp,
|
|
68
83
|
endpointNonce: (0, keys_1.newToken)(32),
|
|
84
|
+
webrtc: false,
|
|
85
|
+
websocket: { urls: ["http://192.168.1.20:3341", "wss://peers.example.com"] },
|
|
69
86
|
},
|
|
70
87
|
}, signalingKey);
|
|
71
|
-
expect((0, _1.verifyPairingSignal)(
|
|
72
|
-
|
|
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,
|
|
88
|
+
expect((0, _1.verifyPairingSignal)(options, signalingKey, {
|
|
89
|
+
nameplate: 42,
|
|
83
90
|
senderRole: "destination",
|
|
84
|
-
nextSequence:
|
|
85
|
-
})).
|
|
86
|
-
expect(() => (0, _1.
|
|
87
|
-
|
|
91
|
+
nextSequence: 2,
|
|
92
|
+
})).toEqual(options);
|
|
93
|
+
expect(() => (0, _1.signPairingSignal)({
|
|
94
|
+
...options,
|
|
95
|
+
payload: { ...options.payload, websocket: { urls: ["ftp://nope"] } },
|
|
96
|
+
}, signalingKey)).toThrow();
|
|
97
|
+
const choice = (0, _1.signPairingSignal)({
|
|
98
|
+
version: 3,
|
|
99
|
+
nameplate: 42,
|
|
100
|
+
senderRole: "source",
|
|
101
|
+
sequence: 2,
|
|
102
|
+
kind: "transport-choice",
|
|
103
|
+
payload: { endpointNonce: (0, keys_1.newToken)(32), selected: "ws", url: "http://192.168.1.20:3341" },
|
|
104
|
+
}, signalingKey);
|
|
105
|
+
expect((0, _1.verifyPairingSignal)(choice, signalingKey, {
|
|
106
|
+
nameplate: 42,
|
|
88
107
|
senderRole: "source",
|
|
89
108
|
nextSequence: 2,
|
|
90
|
-
})).
|
|
109
|
+
})).toEqual(choice);
|
|
110
|
+
const offerSdp = `v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`;
|
|
111
|
+
const signal = (0, _1.signPairingSignal)({
|
|
112
|
+
version: 3,
|
|
113
|
+
nameplate: 42,
|
|
114
|
+
senderRole: "source",
|
|
115
|
+
sequence: 3,
|
|
116
|
+
kind: "offer",
|
|
117
|
+
payload: { type: "offer", sdp: offerSdp },
|
|
118
|
+
}, signalingKey);
|
|
119
|
+
const expected = { nameplate: 42, senderRole: "source", nextSequence: 3 };
|
|
120
|
+
expect((0, _1.verifyPairingSignal)(signal, signalingKey, expected)).toEqual(signal);
|
|
121
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, undefined, expected)).toThrow(/before authentication/);
|
|
122
|
+
expect(() => (0, _1.verifyPairingSignal)({ ...signal, payload: { ...signal.payload, sdp: `${offerSdp}modified` } }, signalingKey, expected)).toThrow(/authentication failed/);
|
|
123
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, signalingKey, { ...expected, senderRole: "destination" })).toThrow(/out of order/);
|
|
124
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, signalingKey, { ...expected, nextSequence: 4 })).toThrow(/out of order/);
|
|
125
|
+
expect(() => (0, _1.verifyPairingSignal)(signal, signalingKey, { ...expected, nameplate: 41 })).toThrow(/out of order/);
|
|
91
126
|
});
|
|
92
127
|
});
|
|
93
|
-
describe("
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
answerFingerprint: (0, _1.extractSha256SdpFingerprint)(`v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`),
|
|
110
|
-
};
|
|
128
|
+
describe("pairing transcript, confirmation, and credentials", () => {
|
|
129
|
+
const source = new device_1.Device();
|
|
130
|
+
const destination = new device_1.Device();
|
|
131
|
+
const sdp = `v=0\r\na=fingerprint:sha-256 ${FINGERPRINT}\r\n`;
|
|
132
|
+
const transcript = {
|
|
133
|
+
version: 3,
|
|
134
|
+
serviceOrigin: ORIGIN,
|
|
135
|
+
nameplate: 42,
|
|
136
|
+
sourceIdentity: (0, keys_1.openSignedObject)(source.getDeviceInfo()),
|
|
137
|
+
destinationIdentity: (0, keys_1.openSignedObject)(destination.getDeviceInfo()),
|
|
138
|
+
sourceEndpointNonce: (0, keys_1.newToken)(32),
|
|
139
|
+
destinationEndpointNonce: (0, keys_1.newToken)(32),
|
|
140
|
+
transport: "wrtc",
|
|
141
|
+
transportBinding: (0, _1.webRtcPairingTransportBinding)(sdp, sdp),
|
|
142
|
+
};
|
|
143
|
+
it("hashes the transcript deterministically and changes with any input", () => {
|
|
111
144
|
const hash = (0, _1.computeDevicePairingTranscriptHash)(transcript);
|
|
112
|
-
|
|
113
|
-
|
|
145
|
+
expect((0, _1.computeDevicePairingTranscriptHash)(transcript)).toBe(hash);
|
|
146
|
+
expect((0, _1.computeDevicePairingTranscriptHash)({ ...transcript, nameplate: 43 })).not.toBe(hash);
|
|
147
|
+
expect(transcript.transportBinding).toBe(`${(0, _1.extractSha256SdpFingerprint)(sdp)}|${(0, _1.extractSha256SdpFingerprint)(sdp)}`);
|
|
148
|
+
expect((0, _1.computeDevicePairingTranscriptHash)({
|
|
114
149
|
...transcript,
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
expect(
|
|
118
|
-
|
|
119
|
-
|
|
150
|
+
transportBinding: transcript.transportBinding.replace("00", "FF"),
|
|
151
|
+
})).not.toBe(hash);
|
|
152
|
+
expect((0, _1.computeDevicePairingTranscriptHash)({
|
|
153
|
+
...transcript,
|
|
154
|
+
transport: "ws",
|
|
155
|
+
transportBinding: "http://192.168.1.20:3341",
|
|
156
|
+
})).not.toBe(hash);
|
|
120
157
|
});
|
|
121
|
-
it("
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
transcriptHash,
|
|
158
|
+
it("confirms only with the same key, role, transcript, and identity", () => {
|
|
159
|
+
const code = (0, _1.parsePairingCode)("42-aardvark-adroitness-acme");
|
|
160
|
+
const sourcePake = (0, _1.startPairingPake)("source", code, ORIGIN);
|
|
161
|
+
const destinationPake = (0, _1.startPairingPake)("destination", code, ORIGIN);
|
|
162
|
+
const keys = sourcePake.finish(destinationPake.share);
|
|
163
|
+
const wrongKeys = (0, _1.startPairingPake)("destination", (0, _1.parsePairingCode)("42-absurd-adroitness-acme"), ORIGIN).finish((0, _1.startPairingPake)("source", code, ORIGIN).share);
|
|
164
|
+
const transcriptHash = (0, _1.computeDevicePairingTranscriptHash)(transcript);
|
|
165
|
+
const confirmation = (0, _1.createPairingConfirmation)({
|
|
130
166
|
role: "destination",
|
|
131
|
-
now: NOW,
|
|
132
|
-
})).toHaveLength(43);
|
|
133
|
-
expect(() => (0, _1.verifySignedPairingApproval)(approval, {
|
|
134
|
-
publicKey: source.publicKey,
|
|
135
167
|
transcriptHash,
|
|
168
|
+
device: transcript.destinationIdentity,
|
|
169
|
+
presentation: { deviceName: "Phone", deviceType: "mobile" },
|
|
170
|
+
}, keys.confirmKey);
|
|
171
|
+
const expected = {
|
|
136
172
|
role: "destination",
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
transcriptHash,
|
|
174
|
+
device: transcript.destinationIdentity,
|
|
175
|
+
};
|
|
176
|
+
expect((0, _1.verifyPairingConfirmation)(confirmation, keys.confirmKey, expected)).toEqual(confirmation);
|
|
177
|
+
expect(() => (0, _1.verifyPairingConfirmation)(confirmation, wrongKeys.confirmKey, expected)).toThrow(/did not match/);
|
|
178
|
+
expect(() => (0, _1.verifyPairingConfirmation)(confirmation, keys.confirmKey, { ...expected, role: "source" })).toThrow(/does not match/);
|
|
179
|
+
expect(() => (0, _1.verifyPairingConfirmation)(confirmation, keys.confirmKey, {
|
|
180
|
+
...expected,
|
|
181
|
+
device: transcript.sourceIdentity,
|
|
182
|
+
})).toThrow(/identity mismatch/);
|
|
183
|
+
expect(() => (0, _1.verifyPairingConfirmation)({ ...confirmation, presentation: { deviceName: "Evil" } }, keys.confirmKey, expected)).toThrow(/did not match/);
|
|
184
|
+
});
|
|
185
|
+
it("requires the expected identities and transcript for credentials and receipt", () => {
|
|
186
|
+
const transcriptHash = (0, keys_1.newToken)(32);
|
|
187
|
+
const expiresAt = NOW + 60_000;
|
|
139
188
|
const credentials = { userId: (0, utils_1.newid)(), secretKey: (0, keys_1.newKeys)().secretKey };
|
|
140
189
|
const destinationInfo = (0, keys_1.openSignedObject)(destination.getDeviceInfo());
|
|
141
190
|
const envelope = (0, _1.createPairingCredentialEnvelope)(source, destinationInfo, credentials, transcriptHash, expiresAt);
|
|
@@ -144,12 +193,7 @@ describe("device pairing transcript and credentials", () => {
|
|
|
144
193
|
sourcePublicBoxKey: source.publicBoxKey,
|
|
145
194
|
transcriptHash,
|
|
146
195
|
now: NOW,
|
|
147
|
-
})).toEqual({
|
|
148
|
-
version: 1,
|
|
149
|
-
transcriptHash,
|
|
150
|
-
expiresAt,
|
|
151
|
-
...credentials,
|
|
152
|
-
});
|
|
196
|
+
})).toEqual({ version: 3, transcriptHash, expiresAt, ...credentials });
|
|
153
197
|
expect(() => (0, _1.openPairingCredentialEnvelope)(new device_1.Device(), envelope, {
|
|
154
198
|
sourcePublicKey: source.publicKey,
|
|
155
199
|
sourcePublicBoxKey: source.publicBoxKey,
|
|
@@ -164,11 +208,4 @@ describe("device pairing transcript and credentials", () => {
|
|
|
164
208
|
transcriptHash,
|
|
165
209
|
})).toEqual(initializedInfo);
|
|
166
210
|
});
|
|
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
211
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./constants";
|
|
2
|
+
export * from "./cpace";
|
|
2
3
|
export * from "./crypto";
|
|
3
|
-
export * from "./
|
|
4
|
+
export * from "./pairing-code";
|
|
5
|
+
export * from "./pgp-word-list";
|
|
4
6
|
export * from "./signaling";
|
|
5
7
|
export * from "./transcript";
|
|
6
8
|
export * from "./types";
|
|
@@ -15,8 +15,10 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./constants"), exports);
|
|
18
|
+
__exportStar(require("./cpace"), exports);
|
|
18
19
|
__exportStar(require("./crypto"), exports);
|
|
19
|
-
__exportStar(require("./
|
|
20
|
+
__exportStar(require("./pairing-code"), exports);
|
|
21
|
+
__exportStar(require("./pgp-word-list"), exports);
|
|
20
22
|
__exportStar(require("./signaling"), exports);
|
|
21
23
|
__exportStar(require("./transcript"), exports);
|
|
22
24
|
__exportStar(require("./types"), exports);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Number of words in a pairing code; each carries one full random byte (24 bits total). */
|
|
2
|
+
export declare const PAIRING_CODE_WORD_COUNT = 3;
|
|
3
|
+
/** The three password words of a pairing code, drawn even/odd/even from the PGP word list. */
|
|
4
|
+
export type PairingWords = readonly [string, string, string];
|
|
5
|
+
/** Parsed `<nameplate>-<word>-<word>-<word>` pairing code. */
|
|
6
|
+
export interface PairingCode {
|
|
7
|
+
/** Small integer selecting the rendezvous room; not part of the PAKE password. */
|
|
8
|
+
nameplate: number;
|
|
9
|
+
/** Lowercase canonical words that form the PAKE password. */
|
|
10
|
+
words: PairingWords;
|
|
11
|
+
}
|
|
12
|
+
/** Samples three words, one uniformly random byte each, alternating even/odd/even lists. */
|
|
13
|
+
export declare function generatePairingWords(): PairingWords;
|
|
14
|
+
/** Formats the code shown to the user and encoded in the QR: `7-guitar-revenge-tunnel`. */
|
|
15
|
+
export declare function formatPairingCode(nameplate: number, words: PairingWords): string;
|
|
16
|
+
/**
|
|
17
|
+
* Parses typed or scanned input. Case-insensitive; hyphen or whitespace separated; a word
|
|
18
|
+
* from the wrong list for its position is rejected immediately (before any network use).
|
|
19
|
+
* @throws with a generic message that never echoes the input
|
|
20
|
+
*/
|
|
21
|
+
export declare function parsePairingCode(text: string): PairingCode;
|
|
22
|
+
/** PAKE password bytes: the canonical lowercase words joined by hyphens (nameplate excluded). */
|
|
23
|
+
export declare function pairingPasswordFromCode(code: PairingCode): Uint8Array;
|
|
24
|
+
/** PAKE channel identifier binding the exchange to one rendezvous service and room. */
|
|
25
|
+
export declare function pairingChannelId(serviceOrigin: string, nameplate: number): Uint8Array;
|
|
26
|
+
/** Returns whether a nameplate is a positive integer within the service pool. */
|
|
27
|
+
export declare function isValidNameplate(value: number): boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PAIRING_CODE_WORD_COUNT = void 0;
|
|
4
|
+
exports.generatePairingWords = generatePairingWords;
|
|
5
|
+
exports.formatPairingCode = formatPairingCode;
|
|
6
|
+
exports.parsePairingCode = parsePairingCode;
|
|
7
|
+
exports.pairingPasswordFromCode = pairingPasswordFromCode;
|
|
8
|
+
exports.pairingChannelId = pairingChannelId;
|
|
9
|
+
exports.isValidNameplate = isValidNameplate;
|
|
10
|
+
const nacl = require("tweetnacl");
|
|
11
|
+
const constants_1 = require("./constants");
|
|
12
|
+
const cpace_1 = require("./cpace");
|
|
13
|
+
const pgp_word_list_1 = require("./pgp-word-list");
|
|
14
|
+
/** Number of words in a pairing code; each carries one full random byte (24 bits total). */
|
|
15
|
+
exports.PAIRING_CODE_WORD_COUNT = 3;
|
|
16
|
+
const textEncoder = new TextEncoder();
|
|
17
|
+
const EVEN_INDEX = new Map(pgp_word_list_1.PGP_EVEN_WORDS.map((word, index) => [word, index]));
|
|
18
|
+
const ODD_INDEX = new Map(pgp_word_list_1.PGP_ODD_WORDS.map((word, index) => [word, index]));
|
|
19
|
+
function wordListForPosition(position) {
|
|
20
|
+
return position % 2 === 0 ? pgp_word_list_1.PGP_EVEN_WORDS : pgp_word_list_1.PGP_ODD_WORDS;
|
|
21
|
+
}
|
|
22
|
+
function wordIndexForPosition(position) {
|
|
23
|
+
return position % 2 === 0 ? EVEN_INDEX : ODD_INDEX;
|
|
24
|
+
}
|
|
25
|
+
/** Samples three words, one uniformly random byte each, alternating even/odd/even lists. */
|
|
26
|
+
function generatePairingWords() {
|
|
27
|
+
const bytes = nacl.randomBytes(exports.PAIRING_CODE_WORD_COUNT);
|
|
28
|
+
return [
|
|
29
|
+
wordListForPosition(0)[bytes[0]],
|
|
30
|
+
wordListForPosition(1)[bytes[1]],
|
|
31
|
+
wordListForPosition(2)[bytes[2]],
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/** Formats the code shown to the user and encoded in the QR: `7-guitar-revenge-tunnel`. */
|
|
35
|
+
function formatPairingCode(nameplate, words) {
|
|
36
|
+
assertNameplate(nameplate);
|
|
37
|
+
return [String(nameplate), ...words].join("-");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parses typed or scanned input. Case-insensitive; hyphen or whitespace separated; a word
|
|
41
|
+
* from the wrong list for its position is rejected immediately (before any network use).
|
|
42
|
+
* @throws with a generic message that never echoes the input
|
|
43
|
+
*/
|
|
44
|
+
function parsePairingCode(text) {
|
|
45
|
+
const parts = text
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.split(/[\s-]+/)
|
|
49
|
+
.filter((part) => part.length > 0);
|
|
50
|
+
if (parts.length !== exports.PAIRING_CODE_WORD_COUNT + 1) {
|
|
51
|
+
throw new Error("Pairing code must be a number followed by three words");
|
|
52
|
+
}
|
|
53
|
+
const nameplate = Number.parseInt(parts[0], 10);
|
|
54
|
+
if (!/^\d+$/.test(parts[0]) || !isValidNameplate(nameplate)) {
|
|
55
|
+
throw new Error("Pairing code has an invalid room number");
|
|
56
|
+
}
|
|
57
|
+
const words = [];
|
|
58
|
+
for (let position = 0; position < exports.PAIRING_CODE_WORD_COUNT; position++) {
|
|
59
|
+
const word = parts[position + 1];
|
|
60
|
+
if (!wordIndexForPosition(position).has(word)) {
|
|
61
|
+
throw new Error(`Pairing code word ${position + 1} is not recognized`);
|
|
62
|
+
}
|
|
63
|
+
words.push(word);
|
|
64
|
+
}
|
|
65
|
+
return { nameplate, words: words };
|
|
66
|
+
}
|
|
67
|
+
/** PAKE password bytes: the canonical lowercase words joined by hyphens (nameplate excluded). */
|
|
68
|
+
function pairingPasswordFromCode(code) {
|
|
69
|
+
return textEncoder.encode(code.words.join("-"));
|
|
70
|
+
}
|
|
71
|
+
/** PAKE channel identifier binding the exchange to one rendezvous service and room. */
|
|
72
|
+
function pairingChannelId(serviceOrigin, nameplate) {
|
|
73
|
+
assertNameplate(nameplate);
|
|
74
|
+
return (0, cpace_1.cpaceLvCat)(textEncoder.encode("peers-device-pairing"), textEncoder.encode(serviceOrigin), textEncoder.encode(String(nameplate)));
|
|
75
|
+
}
|
|
76
|
+
/** Returns whether a nameplate is a positive integer within the service pool. */
|
|
77
|
+
function isValidNameplate(value) {
|
|
78
|
+
return Number.isInteger(value) && value >= 1 && value <= constants_1.DEVICE_PAIRING_MAX_NAMEPLATE;
|
|
79
|
+
}
|
|
80
|
+
function assertNameplate(value) {
|
|
81
|
+
if (!isValidNameplate(value)) {
|
|
82
|
+
throw new Error("Invalid pairing nameplate");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PGP word list: 256 two-syllable "even" words and 256 three-syllable "odd" words.
|
|
3
|
+
*
|
|
4
|
+
* Word `n` in each list encodes the byte value `n`. Pairing codes alternate lists by
|
|
5
|
+
* position (even, odd, even) so a word typed in the wrong slot is detected before the
|
|
6
|
+
* PAKE runs. The list was designed for reading aloud: words are phonetically distinct
|
|
7
|
+
* and stress-pattern distinct across the two lists.
|
|
8
|
+
*
|
|
9
|
+
* Source: https://en.wikipedia.org/wiki/PGP_word_list (lowercased).
|
|
10
|
+
*/
|
|
11
|
+
/** Two-syllable words used for byte positions 0 and 2 of a pairing code. */
|
|
12
|
+
export declare const PGP_EVEN_WORDS: readonly string[];
|
|
13
|
+
/** Three-syllable words used for byte position 1 of a pairing code. */
|
|
14
|
+
export declare const PGP_ODD_WORDS: readonly string[];
|