zapo-js 1.3.0 → 1.5.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.
Files changed (113) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +25 -0
  3. package/dist/auth/WaAuthClient.js +63 -7
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
  7. package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
  8. package/dist/auth/pairing/companion-host.d.ts +105 -0
  9. package/dist/auth/pairing/companion-host.js +148 -0
  10. package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
  11. package/dist/auth/pairing/pairing-code-crypto.js +3 -2
  12. package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
  13. package/dist/auth/pairing/shortcake-crypto.js +106 -0
  14. package/dist/auth/types.d.ts +20 -9
  15. package/dist/client/WaClient.d.ts +15 -5
  16. package/dist/client/WaClient.js +46 -12
  17. package/dist/client/WaClientFactory.d.ts +3 -0
  18. package/dist/client/WaClientFactory.js +27 -7
  19. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  20. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  21. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  22. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  23. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  24. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  25. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  26. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  27. package/dist/client/messaging/companion-host.d.ts +43 -0
  28. package/dist/client/messaging/companion-host.js +61 -0
  29. package/dist/client/messaging/key-protocol.js +25 -1
  30. package/dist/client/persistence/companion-host.d.ts +36 -0
  31. package/dist/client/persistence/companion-host.js +57 -0
  32. package/dist/client/plugins/install.d.ts +2 -1
  33. package/dist/client/plugins/install.js +44 -11
  34. package/dist/client/types.d.ts +90 -7
  35. package/dist/esm/auth/WaAuthClient.js +64 -8
  36. package/dist/esm/auth/credentials-flow.js +36 -3
  37. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  38. package/dist/esm/auth/pairing/companion-host.js +140 -0
  39. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  40. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  41. package/dist/esm/client/WaClient.js +46 -12
  42. package/dist/esm/client/WaClientFactory.js +28 -8
  43. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  44. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  45. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  46. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  47. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  48. package/dist/esm/client/messaging/companion-host.js +56 -0
  49. package/dist/esm/client/messaging/key-protocol.js +25 -1
  50. package/dist/esm/client/persistence/companion-host.js +54 -0
  51. package/dist/esm/client/plugins/install.js +44 -11
  52. package/dist/esm/index.js +5 -2
  53. package/dist/esm/message/WaMessageClient.js +3 -2
  54. package/dist/esm/message/context-info.js +4 -0
  55. package/dist/esm/protocol/constants.js +4 -3
  56. package/dist/esm/protocol/device-identity.js +18 -0
  57. package/dist/esm/protocol/group.js +6 -0
  58. package/dist/esm/protocol/message.js +12 -0
  59. package/dist/esm/protocol/nodes.js +17 -1
  60. package/dist/esm/protocol/notification.js +3 -1
  61. package/dist/esm/retry/replay.js +7 -15
  62. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  63. package/dist/esm/signal/attestation/constants.js +2 -0
  64. package/dist/esm/signal/index.js +1 -0
  65. package/dist/esm/transport/index.js +1 -1
  66. package/dist/esm/transport/node/builders/group.js +4 -3
  67. package/dist/esm/transport/node/builders/mobile.js +113 -0
  68. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  69. package/dist/esm/transport/noise/WaClientPayload.js +17 -8
  70. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  71. package/dist/esm/util/index.js +1 -0
  72. package/dist/index.d.ts +11 -4
  73. package/dist/index.js +13 -4
  74. package/dist/message/WaMessageClient.js +2 -1
  75. package/dist/message/context-info.d.ts +2 -0
  76. package/dist/message/context-info.js +5 -0
  77. package/dist/protocol/constants.d.ts +6 -4
  78. package/dist/protocol/constants.js +8 -2
  79. package/dist/protocol/device-identity.d.ts +25 -0
  80. package/dist/protocol/device-identity.js +21 -0
  81. package/dist/protocol/group.d.ts +6 -0
  82. package/dist/protocol/group.js +7 -1
  83. package/dist/protocol/message.d.ts +14 -0
  84. package/dist/protocol/message.js +13 -1
  85. package/dist/protocol/nodes.d.ts +16 -0
  86. package/dist/protocol/nodes.js +17 -1
  87. package/dist/protocol/notification.d.ts +2 -0
  88. package/dist/protocol/notification.js +3 -1
  89. package/dist/retry/replay.d.ts +1 -0
  90. package/dist/retry/replay.js +6 -14
  91. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  92. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  93. package/dist/signal/attestation/constants.d.ts +2 -0
  94. package/dist/signal/attestation/constants.js +3 -1
  95. package/dist/signal/index.d.ts +1 -0
  96. package/dist/signal/index.js +13 -1
  97. package/dist/transport/index.d.ts +2 -2
  98. package/dist/transport/index.js +4 -3
  99. package/dist/transport/node/builders/group.js +4 -3
  100. package/dist/transport/node/builders/mobile.d.ts +68 -0
  101. package/dist/transport/node/builders/mobile.js +120 -0
  102. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  103. package/dist/transport/node/builders/shortcake.js +57 -0
  104. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  105. package/dist/transport/noise/WaClientPayload.js +17 -7
  106. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  107. package/dist/transport/wa-version-fetcher.js +156 -0
  108. package/dist/util/index.d.ts +1 -0
  109. package/dist/util/index.js +3 -1
  110. package/package.json +1 -1
  111. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  112. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  113. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -0,0 +1,105 @@
1
+ import { type SignalKeyPair } from '../../crypto';
2
+ /**
3
+ * `@sensitive` `advSecretKey` is companion secret material; consumers must not
4
+ * persist it without encryption at rest.
5
+ *
6
+ * Fields decoded from a companion pairing QR string. The companion (the device
7
+ * being linked) generates these; the primary consumes them to sign the device
8
+ * identity. `advSecretKey` is the companion's secret, not the primary's.
9
+ */
10
+ export interface ParsedCompanionQr {
11
+ readonly ref: string;
12
+ readonly noisePublicKey: Uint8Array;
13
+ readonly identityPublicKey: Uint8Array;
14
+ readonly advSecretKey: Uint8Array;
15
+ readonly platform: string;
16
+ }
17
+ /**
18
+ * Parses a companion pairing QR of the form
19
+ * `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
20
+ * contain commas, so the four trailing fields are taken from the end.
21
+ *
22
+ * @throws when fewer than 5 comma-separated fields are present.
23
+ */
24
+ export declare function parseCompanionQr(qr: string): ParsedCompanionQr;
25
+ /** `@sensitive` Carries the primary's `accountIdentityKeyPair` private key. */
26
+ export interface BuildSignedKeyIndexListInput {
27
+ readonly accountIdentityKeyPair: SignalKeyPair;
28
+ readonly rawId: number;
29
+ readonly currentIndex: number;
30
+ readonly timestampSeconds: number;
31
+ readonly validIndexes: readonly number[];
32
+ }
33
+ /**
34
+ * Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
35
+ * set. The zero-valued `accountType` enum is omitted to match the phone's byte
36
+ * layout.
37
+ */
38
+ export declare function buildSignedKeyIndexList(input: BuildSignedKeyIndexListInput): Promise<Uint8Array>;
39
+ /**
40
+ * `@sensitive` Carries the primary's `accountIdentityKeyPair` private key and the
41
+ * companion's `advSecretKey`; do not persist unencrypted.
42
+ */
43
+ export interface BuildSignedCompanionIdentityInput {
44
+ readonly accountIdentityKeyPair: SignalKeyPair;
45
+ readonly companionIdentityPublicKey: Uint8Array;
46
+ readonly advSecretKey: Uint8Array;
47
+ readonly rawId: number;
48
+ readonly keyIndex: number;
49
+ readonly timestampSeconds: number;
50
+ readonly validIndexes: readonly number[];
51
+ }
52
+ export interface SignedCompanionIdentity {
53
+ /** `ADVSignedDeviceIdentityHMAC` bytes for the `<device-identity>` node. */
54
+ readonly deviceIdentityBytes: Uint8Array;
55
+ /** `ADVSignedKeyIndexList` bytes for the `<key-index-list>` node. */
56
+ readonly keyIndexListBytes: Uint8Array;
57
+ }
58
+ /**
59
+ * Builds the signed device identity and key-index list for a linking companion.
60
+ * The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
61
+ * reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
62
+ * so the signed `details` match what the companion verifies.
63
+ */
64
+ export declare function buildSignedCompanionIdentity(input: BuildSignedCompanionIdentityInput): Promise<SignedCompanionIdentity>;
65
+ /**
66
+ * Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
67
+ * `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
68
+ * verified against a live server from this implementation; see
69
+ * `CompanionHostOptions.includePem`.
70
+ */
71
+ export declare function derivePemKey(advSecretKey: Uint8Array, companionNoisePublicKey: Uint8Array): Uint8Array;
72
+ /**
73
+ * `@sensitive` Holds the `primaryEphemeralKeyPair` private key and the derived
74
+ * `sharedEphemeral`; keep in memory only, never persist unencrypted.
75
+ */
76
+ export interface LinkCodePrimaryHello {
77
+ readonly primaryEphemeralKeyPair: SignalKeyPair;
78
+ /** `salt(32) || counter(16) || AES-CTR(primaryEphemeralPub)` for `primary_hello`. */
79
+ readonly wrappedPrimaryEphemeralPub: Uint8Array;
80
+ /** ECDH(primaryEphemeralPriv, companionEphemeralPub); stashed for the finish step. */
81
+ readonly sharedEphemeral: Uint8Array;
82
+ }
83
+ /**
84
+ * Link-code handshake, primary step 1: unwrap the companion ephemeral with the
85
+ * pairing code, generate the primary ephemeral, compute the shared ECDH secret,
86
+ * and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
87
+ * companion's `createCompanionHello` + primary-ephemeral unwrap.
88
+ */
89
+ export declare function preparePrimaryHello(args: {
90
+ readonly pairingCode: string;
91
+ readonly wrappedCompanionEphemeralPub: Uint8Array;
92
+ }): Promise<LinkCodePrimaryHello>;
93
+ /**
94
+ * Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
95
+ * and derive the shared `advSecret` (byte-identical to what the companion
96
+ * derives). Validates that the bundle binds both identities.
97
+ *
98
+ * @throws when the bundle's embedded identities do not match.
99
+ */
100
+ export declare function completePrimaryHandshake(args: {
101
+ readonly sharedEphemeral: Uint8Array;
102
+ readonly wrappedKeyBundle: Uint8Array;
103
+ readonly companionIdentityPub: Uint8Array;
104
+ readonly primaryIdentityKeyPair: SignalKeyPair;
105
+ }): Promise<Uint8Array>;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseCompanionQr = parseCompanionQr;
4
+ exports.buildSignedKeyIndexList = buildSignedKeyIndexList;
5
+ exports.buildSignedCompanionIdentity = buildSignedCompanionIdentity;
6
+ exports.derivePemKey = derivePemKey;
7
+ exports.preparePrimaryHello = preparePrimaryHello;
8
+ exports.completePrimaryHandshake = completePrimaryHandshake;
9
+ const _crypto_1 = require("../../crypto");
10
+ const _proto_1 = require("../../proto");
11
+ const _protocol_1 = require("../../protocol");
12
+ const _signal_1 = require("../../signal");
13
+ const bytes_1 = require("../../util/bytes");
14
+ const PEM_HKDF_INFO = bytes_1.TEXT_ENCODER.encode('Canonical Ent Companion Nonce Encrypt');
15
+ const PBKDF2_ITERATIONS = 131072;
16
+ const PAIRING_AES_KEY_BYTES = 32;
17
+ /**
18
+ * Parses a companion pairing QR of the form
19
+ * `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
20
+ * contain commas, so the four trailing fields are taken from the end.
21
+ *
22
+ * @throws when fewer than 5 comma-separated fields are present.
23
+ */
24
+ function parseCompanionQr(qr) {
25
+ const parts = qr.split(',');
26
+ if (parts.length < 5) {
27
+ throw new Error(`companion qr must have at least 5 comma-separated parts, got ${parts.length}`);
28
+ }
29
+ const platform = parts[parts.length - 1];
30
+ const advSecretB64 = parts[parts.length - 2];
31
+ const identityPubB64 = parts[parts.length - 3];
32
+ const noisePubB64 = parts[parts.length - 4];
33
+ const ref = parts.slice(0, parts.length - 4).join(',');
34
+ return {
35
+ ref,
36
+ noisePublicKey: (0, bytes_1.decodeBase64Url)(noisePubB64, 'companionQr.noisePublicKey'),
37
+ identityPublicKey: (0, bytes_1.decodeBase64Url)(identityPubB64, 'companionQr.identityPublicKey'),
38
+ advSecretKey: (0, bytes_1.decodeBase64Url)(advSecretB64, 'companionQr.advSecretKey'),
39
+ platform
40
+ };
41
+ }
42
+ /**
43
+ * Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
44
+ * set. The zero-valued `accountType` enum is omitted to match the phone's byte
45
+ * layout.
46
+ */
47
+ async function buildSignedKeyIndexList(input) {
48
+ const details = _proto_1.proto.ADVKeyIndexList.encode({
49
+ rawId: input.rawId,
50
+ timestamp: input.timestampSeconds,
51
+ currentIndex: input.currentIndex,
52
+ validIndexes: [...input.validIndexes]
53
+ }).finish();
54
+ const accountSignature = await (0, _signal_1.signKeyIndexList)(details, input.accountIdentityKeyPair);
55
+ return _proto_1.proto.ADVSignedKeyIndexList.encode({ details, accountSignature }).finish();
56
+ }
57
+ /**
58
+ * Builds the signed device identity and key-index list for a linking companion.
59
+ * The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
60
+ * reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
61
+ * so the signed `details` match what the companion verifies.
62
+ */
63
+ async function buildSignedCompanionIdentity(input) {
64
+ const accountSignatureKey = (0, _crypto_1.toRawPubKey)(input.accountIdentityKeyPair.pubKey);
65
+ const details = _proto_1.proto.ADVDeviceIdentity.encode({
66
+ rawId: input.rawId,
67
+ timestamp: input.timestampSeconds,
68
+ keyIndex: input.keyIndex
69
+ }).finish();
70
+ const accountSignature = await (0, _signal_1.generateDeviceIdentityAccountSignature)(details, input.companionIdentityPublicKey, input.accountIdentityKeyPair);
71
+ const signedIdentity = _proto_1.proto.ADVSignedDeviceIdentity.encode({
72
+ details,
73
+ accountSignatureKey,
74
+ accountSignature
75
+ }).finish();
76
+ const hmac = (0, _signal_1.computeAdvIdentityHmac)(input.advSecretKey, signedIdentity);
77
+ const deviceIdentityBytes = _proto_1.proto.ADVSignedDeviceIdentityHMAC.encode({
78
+ details: signedIdentity,
79
+ hmac
80
+ }).finish();
81
+ const keyIndexListBytes = await buildSignedKeyIndexList({
82
+ accountIdentityKeyPair: input.accountIdentityKeyPair,
83
+ rawId: input.rawId,
84
+ currentIndex: input.keyIndex,
85
+ timestampSeconds: input.timestampSeconds,
86
+ validIndexes: input.validIndexes
87
+ });
88
+ return { deviceIdentityBytes, keyIndexListBytes };
89
+ }
90
+ /**
91
+ * Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
92
+ * `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
93
+ * verified against a live server from this implementation; see
94
+ * `CompanionHostOptions.includePem`.
95
+ */
96
+ function derivePemKey(advSecretKey, companionNoisePublicKey) {
97
+ return (0, _crypto_1.hkdf)(advSecretKey, companionNoisePublicKey, PEM_HKDF_INFO, 32);
98
+ }
99
+ /**
100
+ * Link-code handshake, primary step 1: unwrap the companion ephemeral with the
101
+ * pairing code, generate the primary ephemeral, compute the shared ECDH secret,
102
+ * and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
103
+ * companion's `createCompanionHello` + primary-ephemeral unwrap.
104
+ */
105
+ async function preparePrimaryHello(args) {
106
+ if (args.wrappedCompanionEphemeralPub.length < 48) {
107
+ throw new Error(`wrapped companion ephemeral pub too short: ${args.wrappedCompanionEphemeralPub.length} bytes (need >= 48)`);
108
+ }
109
+ const codeBytes = bytes_1.TEXT_ENCODER.encode(args.pairingCode);
110
+ const companionCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, args.wrappedCompanionEphemeralPub.subarray(0, 32), PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
111
+ const companionEphemeralPub = (0, _crypto_1.aesCtrDecrypt)(companionCipherKey, args.wrappedCompanionEphemeralPub.subarray(32, 48), args.wrappedCompanionEphemeralPub.subarray(48));
112
+ const [primaryEphemeralKeyPair, salt, counter] = await Promise.all([
113
+ _crypto_1.X25519.generateKeyPair(),
114
+ (0, _crypto_1.randomBytesAsync)(32),
115
+ (0, _crypto_1.randomBytesAsync)(16)
116
+ ]);
117
+ const primaryCipherKey = await (0, _crypto_1.pbkdf2Sha256)(codeBytes, salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
118
+ const encrypted = (0, _crypto_1.aesCtrEncrypt)(primaryCipherKey, counter, primaryEphemeralKeyPair.pubKey);
119
+ const sharedEphemeral = await _crypto_1.X25519.scalarMult(primaryEphemeralKeyPair.privKey, companionEphemeralPub);
120
+ return {
121
+ primaryEphemeralKeyPair,
122
+ wrappedPrimaryEphemeralPub: (0, bytes_1.concatBytes)([salt, counter, encrypted]),
123
+ sharedEphemeral
124
+ };
125
+ }
126
+ /**
127
+ * Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
128
+ * and derive the shared `advSecret` (byte-identical to what the companion
129
+ * derives). Validates that the bundle binds both identities.
130
+ *
131
+ * @throws when the bundle's embedded identities do not match.
132
+ */
133
+ async function completePrimaryHandshake(args) {
134
+ const bundleEncryptionKey = (0, _crypto_1.hkdf)(args.sharedEphemeral, args.wrappedKeyBundle.subarray(0, 32), _protocol_1.WA_PAIRING_KDF_INFO.LINK_CODE_BUNDLE, 32);
135
+ const plaintextBundle = (0, _crypto_1.aesGcmDecrypt)(bundleEncryptionKey, args.wrappedKeyBundle.subarray(32, 44), args.wrappedKeyBundle.subarray(44));
136
+ const bundleCompanionIdentity = plaintextBundle.subarray(0, 32);
137
+ const bundlePrimaryIdentity = plaintextBundle.subarray(32, 64);
138
+ const bundleSecret = plaintextBundle.subarray(64);
139
+ const rawCompanionIdentity = (0, _crypto_1.toRawPubKey)(args.companionIdentityPub);
140
+ if (!(0, bytes_1.uint8Equal)(bundlePrimaryIdentity, (0, _crypto_1.toRawPubKey)(args.primaryIdentityKeyPair.pubKey))) {
141
+ throw new Error('link-code bundle primary identity mismatch');
142
+ }
143
+ if (!(0, bytes_1.uint8Equal)(bundleCompanionIdentity, rawCompanionIdentity)) {
144
+ throw new Error('link-code bundle companion identity mismatch');
145
+ }
146
+ const sharedIdentity = await _crypto_1.X25519.scalarMult(args.primaryIdentityKeyPair.privKey, rawCompanionIdentity);
147
+ return (0, _crypto_1.hkdf)((0, bytes_1.concatBytes)([args.sharedEphemeral, sharedIdentity, bundleSecret]), null, _protocol_1.WA_PAIRING_KDF_INFO.ADV_SECRET, 32);
148
+ }
@@ -1,5 +1,6 @@
1
1
  import type { SignalKeyPair } from '../../crypto/curves/types';
2
2
  export declare const PBKDF2_ITERATIONS: number;
3
+ export declare function encodeBytesToCrockford(bytes: Uint8Array): string;
3
4
  export declare function normalizeCustomPairingCode(input: string): string;
4
5
  export declare function createCompanionHello(options?: {
5
6
  readonly customCode?: string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PBKDF2_ITERATIONS = void 0;
4
+ exports.encodeBytesToCrockford = encodeBytesToCrockford;
4
5
  exports.normalizeCustomPairingCode = normalizeCustomPairingCode;
5
6
  exports.createCompanionHello = createCompanionHello;
6
7
  exports.completeCompanionFinish = completeCompanionFinish;
@@ -11,7 +12,7 @@ const bytes_1 = require("../../util/bytes");
11
12
  const CROCKFORD_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTVWXYZ';
12
13
  exports.PBKDF2_ITERATIONS = 2 << 16;
13
14
  const PAIRING_AES_KEY_BYTES = 32;
14
- function bytesToCrockford(bytes) {
15
+ function encodeBytesToCrockford(bytes) {
15
16
  let bitCount = 0;
16
17
  let value = 0;
17
18
  let out = '';
@@ -48,7 +49,7 @@ async function createCompanionHello(options = {}) {
48
49
  (0, _crypto_1.randomBytesAsync)(16),
49
50
  normalizedCustomCode !== null ? Promise.resolve(null) : (0, _crypto_1.randomBytesAsync)(5)
50
51
  ]);
51
- const pairingCode = normalizedCustomCode ?? bytesToCrockford(codeBytes);
52
+ const pairingCode = normalizedCustomCode ?? encodeBytesToCrockford(codeBytes);
52
53
  const cipherKey = await (0, _crypto_1.pbkdf2Sha256)(bytes_1.TEXT_ENCODER.encode(pairingCode), salt, exports.PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
53
54
  const encrypted = (0, _crypto_1.aesCtrEncrypt)(cipherKey, counter, companionEphemeralKeyPair.pubKey);
54
55
  return {
@@ -0,0 +1,59 @@
1
+ import type { SignalKeyPair } from '../../crypto/curves/types';
2
+ import { proto } from '../../proto';
3
+ /**
4
+ * Decoded `PrimaryEphemeralIdentity` the primary device returns: its ephemeral
5
+ * X25519 public key (32B) and a 32B nonce used to derive the verification code.
6
+ */
7
+ export interface ShortcakePrimaryEphemeralIdentity {
8
+ readonly publicKey: Uint8Array;
9
+ readonly nonce: Uint8Array;
10
+ }
11
+ /**
12
+ * @sensitive Carries the companion's ephemeral X25519 private key
13
+ * (`keyPair.privKey`) and the pre-image `companionNonce`. Hold these in memory
14
+ * for the handshake only; never log or `JSON.stringify` an instance.
15
+ */
16
+ export interface ShortcakeCompanionEphemeralIdentity {
17
+ /** Companion ephemeral X25519 keypair (private half kept for the ECDH). */
18
+ readonly keyPair: SignalKeyPair;
19
+ /** 32 random bytes committed up-front, revealed only after the primary replies. */
20
+ readonly companionNonce: Uint8Array;
21
+ /** Encoded `CompanionEphemeralIdentity` proto (committed + sent in the prologue). */
22
+ readonly companionEphemeralIdentityBytes: Uint8Array;
23
+ /** `SHA-256(companionEphemeralIdentity ‖ companionNonce)` – the commitment hash. */
24
+ readonly commitmentHash: Uint8Array;
25
+ /** Encoded `ProloguePayload` proto ready for the `SetPasskeyPrologue` IQ. */
26
+ readonly prologuePayloadBytes: Uint8Array;
27
+ }
28
+ /**
29
+ * Generates the companion's ephemeral identity + commitment for a Shortcake
30
+ * prologue. The companion publishes a commitment to its nonce now and reveals
31
+ * the nonce later (after the primary's identity arrives) so the primary cannot
32
+ * grind the verification code.
33
+ */
34
+ export declare function generateCompanionEphemeralIdentity(args: {
35
+ readonly ref: string;
36
+ readonly deviceType: proto.DeviceProps.PlatformType;
37
+ }): Promise<ShortcakeCompanionEphemeralIdentity>;
38
+ /** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
39
+ export declare function decodePrimaryEphemeralIdentity(bytes: Uint8Array): ShortcakePrimaryEphemeralIdentity;
40
+ /**
41
+ * Derives the short verification code shown on both devices:
42
+ * `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
43
+ */
44
+ export declare function deriveVerificationCode(companionNonce: Uint8Array, primary: ShortcakePrimaryEphemeralIdentity): string;
45
+ /**
46
+ * Derives the AES-GCM key that protects the pairing request:
47
+ * `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
48
+ */
49
+ export declare function deriveEncryptionKey(args: {
50
+ readonly companionPrivKey: Uint8Array;
51
+ readonly primaryPublicKey: Uint8Array;
52
+ readonly deviceType: proto.DeviceProps.PlatformType;
53
+ readonly ref: string;
54
+ }): Promise<Uint8Array>;
55
+ /**
56
+ * Encrypts the pairing request plaintext under the derived key and returns the
57
+ * encoded `EncryptedPairingRequest` proto (random 12-byte IV).
58
+ */
59
+ export declare function encryptPairingRequest(encryptionKey: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateCompanionEphemeralIdentity = generateCompanionEphemeralIdentity;
4
+ exports.decodePrimaryEphemeralIdentity = decodePrimaryEphemeralIdentity;
5
+ exports.deriveVerificationCode = deriveVerificationCode;
6
+ exports.deriveEncryptionKey = deriveEncryptionKey;
7
+ exports.encryptPairingRequest = encryptPairingRequest;
8
+ const pairing_code_crypto_1 = require("../pairing/pairing-code-crypto");
9
+ const _crypto_1 = require("../../crypto");
10
+ const X25519_1 = require("../../crypto/curves/X25519");
11
+ const _proto_1 = require("../../proto");
12
+ const bytes_1 = require("../../util/bytes");
13
+ /**
14
+ * Cryptographic core of the WhatsApp "Shortcake" companion-linking protocol
15
+ * (the device-link flow the official clients call CRSC / Shortcake).
16
+ *
17
+ * This module implements ONLY the wire/crypto half: ephemeral X25519 key
18
+ * exchange, the commit-reveal nonce, the verification code, and the AES-GCM
19
+ * pairing envelope. The WebAuthn/passkey assertion that gates the prologue is
20
+ * NOT produced here – it is an opaque input supplied by the caller (so the
21
+ * credential source stays out of the protocol layer).
22
+ *
23
+ * Mirrors `WAWebShortcakeLinkingAlgorithm` from the web client.
24
+ */
25
+ const NONCE_BYTES = 32;
26
+ const VERIFICATION_CODE_BYTES = 5;
27
+ const GCM_IV_BYTES = 12;
28
+ const ENCRYPTION_KEY_BYTES = 32;
29
+ const EPHEMERAL_PUBLIC_KEY_BYTES = 32;
30
+ /** HKDF `info` for the pairing-request encryption key. */
31
+ const ENCRYPTION_KEY_INFO = bytes_1.TEXT_ENCODER.encode('Pairing Information Encryption Key');
32
+ /**
33
+ * Generates the companion's ephemeral identity + commitment for a Shortcake
34
+ * prologue. The companion publishes a commitment to its nonce now and reveals
35
+ * the nonce later (after the primary's identity arrives) so the primary cannot
36
+ * grind the verification code.
37
+ */
38
+ async function generateCompanionEphemeralIdentity(args) {
39
+ const [keyPair, companionNonce] = await Promise.all([
40
+ X25519_1.X25519.generateKeyPair(),
41
+ (0, _crypto_1.randomBytesAsync)(NONCE_BYTES)
42
+ ]);
43
+ const companionEphemeralIdentityBytes = _proto_1.proto.CompanionEphemeralIdentity.encode({
44
+ publicKey: keyPair.pubKey,
45
+ deviceType: args.deviceType,
46
+ ref: args.ref
47
+ }).finish();
48
+ const commitmentHash = (0, _crypto_1.sha256)((0, bytes_1.concatBytes)([companionEphemeralIdentityBytes, companionNonce]));
49
+ const prologuePayloadBytes = _proto_1.proto.ProloguePayload.encode({
50
+ companionEphemeralIdentity: companionEphemeralIdentityBytes,
51
+ commitment: { hash: commitmentHash }
52
+ }).finish();
53
+ return {
54
+ keyPair,
55
+ companionNonce,
56
+ companionEphemeralIdentityBytes,
57
+ commitmentHash,
58
+ prologuePayloadBytes
59
+ };
60
+ }
61
+ /** Parses + validates a `PrimaryEphemeralIdentity` proto from the primary. */
62
+ function decodePrimaryEphemeralIdentity(bytes) {
63
+ const decoded = _proto_1.proto.PrimaryEphemeralIdentity.decode(bytes);
64
+ const publicKey = decoded.publicKey;
65
+ const nonce = decoded.nonce;
66
+ if (!publicKey || publicKey.length !== EPHEMERAL_PUBLIC_KEY_BYTES) {
67
+ throw new Error('shortcake: PrimaryEphemeralIdentity.publicKey must be 32 bytes');
68
+ }
69
+ if (!nonce || nonce.length !== NONCE_BYTES) {
70
+ throw new Error('shortcake: PrimaryEphemeralIdentity.nonce must be 32 bytes');
71
+ }
72
+ return { publicKey, nonce };
73
+ }
74
+ /**
75
+ * Derives the short verification code shown on both devices:
76
+ * `Crockford32( primaryNonce[0..5] XOR SHA-256(companionNonce ‖ primaryPubKey)[0..5] )`.
77
+ */
78
+ function deriveVerificationCode(companionNonce, primary) {
79
+ const digest = (0, _crypto_1.sha256)((0, bytes_1.concatBytes)([companionNonce, primary.publicKey]));
80
+ const code = new Uint8Array(VERIFICATION_CODE_BYTES);
81
+ for (let i = 0; i < VERIFICATION_CODE_BYTES; i += 1) {
82
+ code[i] = primary.nonce[i] ^ digest[i];
83
+ }
84
+ return (0, pairing_code_crypto_1.encodeBytesToCrockford)(code);
85
+ }
86
+ /**
87
+ * Derives the AES-GCM key that protects the pairing request:
88
+ * `HKDF( X25519(companionPriv, primaryPub), salt="Companion Pairing <deviceType> with ref <ref>", info="Pairing Information Encryption Key" )`.
89
+ */
90
+ async function deriveEncryptionKey(args) {
91
+ const shared = await X25519_1.X25519.scalarMult(args.companionPrivKey, args.primaryPublicKey);
92
+ const salt = bytes_1.TEXT_ENCODER.encode(`Companion Pairing ${String(args.deviceType)} with ref ${args.ref}`);
93
+ return (0, _crypto_1.hkdf)(shared, salt, ENCRYPTION_KEY_INFO, ENCRYPTION_KEY_BYTES);
94
+ }
95
+ /**
96
+ * Encrypts the pairing request plaintext under the derived key and returns the
97
+ * encoded `EncryptedPairingRequest` proto (random 12-byte IV).
98
+ */
99
+ async function encryptPairingRequest(encryptionKey, plaintext) {
100
+ if (encryptionKey.length !== ENCRYPTION_KEY_BYTES) {
101
+ throw new Error('shortcake: encryption key must be 32 bytes');
102
+ }
103
+ const iv = await (0, _crypto_1.randomBytesAsync)(GCM_IV_BYTES);
104
+ const encryptedPayload = (0, _crypto_1.aesGcmEncrypt)(encryptionKey, iv, plaintext);
105
+ return _proto_1.proto.EncryptedPairingRequest.encode({ encryptedPayload, iv }).finish();
106
+ }
@@ -39,10 +39,11 @@ export type WaAuthSocketOptions = Pick<WaCommsConfig, 'url' | 'urls' | 'protocol
39
39
  };
40
40
  };
41
41
  /**
42
- * WhatsApp Web version supplied to {@link WaAuthClientOptions.version}. Either
43
- * a literal `'x.y.z'` string or an async resolver invoked once per connect,
44
- * letting callers refresh the version (e.g. via `fetchLatestWaWebVersion`)
45
- * without rebuilding the client.
42
+ * Version supplied to {@link WaAuthClientOptions.version}. Either a literal
43
+ * dotted-numeric string or an async resolver invoked once per connect,
44
+ * letting callers refresh the version (e.g. via `fetchLatestWaWebVersion` or
45
+ * `fetchLatestWaMobileVersion`) without rebuilding the client. See
46
+ * {@link WaAuthClientOptions.version} for the per-transport part-count rules.
46
47
  */
47
48
  export type WaVersionResolver = string | (() => string | Promise<string>);
48
49
  export interface WaAuthClientOptions {
@@ -70,11 +71,21 @@ export interface WaAuthClientOptions {
70
71
  */
71
72
  readonly requireFullSync?: boolean;
72
73
  /**
73
- * WhatsApp Web version the client advertises. Either a `'x.y.z'` literal
74
- * or a (sync/async) resolver invoked once per connect – use the resolver
75
- * form together with {@link fetchLatestWaWebVersion} to refresh the
76
- * version when the hardcoded default starts hitting `failure_client_too_old`.
77
- * Defaults to a tested production version.
74
+ * Version the client advertises. Either a dotted-numeric literal or a
75
+ * (sync/async) resolver invoked once per connect – use the resolver form
76
+ * together with {@link fetchLatestWaWebVersion} /
77
+ * {@link fetchLatestWaMobileVersion} to refresh the version when the
78
+ * hardcoded default starts hitting `failure_client_too_old`.
79
+ *
80
+ * The accepted shape depends on the transport resolved for the connect:
81
+ * - **Web** sessions take a 3- to 5-part version (`2.3000.x[.y.z]`); all
82
+ * supplied parts are advertised in the noise payload.
83
+ * - **Mobile** sessions take exactly a 4-part Android app version
84
+ * (`2.26.27.70`); it overrides `mobileTransport.deviceInfo.appVersion`
85
+ * in the login payload.
86
+ *
87
+ * An invalid part count for the resolved transport throws on connect.
88
+ * Defaults to a tested production version per transport.
78
89
  */
79
90
  readonly version?: WaVersionResolver;
80
91
  /**
@@ -8,6 +8,7 @@ import type { WaEmailCoordinator } from './coordinators/WaEmailCoordinator';
8
8
  import type { WaGroupCoordinator } from './coordinators/WaGroupCoordinator';
9
9
  import type { WaLowLevelCoordinator } from './coordinators/WaLowLevelCoordinator';
10
10
  import type { WaMessageCoordinator } from './coordinators/WaMessageCoordinator';
11
+ import type { WaMobileCoordinator } from './coordinators/WaMobileCoordinator';
11
12
  import type { WaNewsletterCoordinator } from './coordinators/WaNewsletterCoordinator';
12
13
  import type { WaPresenceCoordinator } from './coordinators/WaPresenceCoordinator';
13
14
  import type { WaPrivacyCoordinator } from './coordinators/WaPrivacyCoordinator';
@@ -31,11 +32,13 @@ declare class WaClientImpl extends EventEmitter {
31
32
  private activeIncomingHandlers;
32
33
  private readonly incomingHandlersDrainedWaiters;
33
34
  private disposePlugins;
35
+ private readonly pluginInstallInput;
34
36
  /**
35
37
  * @param options Client configuration (store, transport, addons, history...).
36
38
  * @param logger Optional structured logger. Defaults to a `ConsoleLogger('info')`.
37
39
  */
38
40
  constructor(options: WaClientOptions, logger?: Logger);
41
+ private installPlugins;
39
42
  private runClientTooOldRecover;
40
43
  /** Strongly-typed `EventEmitter#on` over {@link WaClientEventMap}. */
41
44
  on<K extends keyof WaClientEventMap>(event: K, listener: WaClientEventMap[K]): this;
@@ -100,11 +103,12 @@ declare class WaClientImpl extends EventEmitter {
100
103
  connect(): Promise<void>;
101
104
  /**
102
105
  * Closes the transport gracefully: pauses incoming events, flushes the
103
- * write-behind persistence queue, and emits a `connection` close event
104
- * with reason `client_disconnected`. Does not clear stored credentials -
105
- * call {@link connect} again to resume the same session. There is no
106
- * built-in auto-reconnect; subscribe to `connection: { status: 'close' }`
107
- * and decide your own backoff.
106
+ * write-behind persistence queue, disposes installed plugins, and emits a
107
+ * `connection` close event with reason `client_disconnected`. Does not
108
+ * clear stored credentials - call {@link connect} again to resume the same
109
+ * session, which reinstalls the plugins. There is no built-in
110
+ * auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
111
+ * your own backoff.
108
112
  */
109
113
  disconnect(): Promise<void>;
110
114
  /**
@@ -166,6 +170,12 @@ declare class WaClientImpl extends EventEmitter {
166
170
  * via `options.mobileTransport`. See {@link WaEmailCoordinator}.
167
171
  */
168
172
  get email(): WaEmailCoordinator;
173
+ /**
174
+ * Mobile coordinator: host and manage companion devices linked to this
175
+ * mobile-primary account (link via QR or pairing code, list, revoke).
176
+ * Requires a registered primary session. See {@link WaMobileCoordinator}.
177
+ */
178
+ get mobile(): WaMobileCoordinator;
169
179
  /**
170
180
  * Unpairs this companion device by removing it server-side. Requires an
171
181
  * authenticated session; throws when no `meJid` is present.