@gryt/crypto 0.1.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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Whether a conversation can be encrypted, and doing it (GRYT-729).
3
+ *
4
+ * Everything underneath this has existed for a while and had no caller.
5
+ * `sealMessage` since GRYT-718, keys published and pinned since GRYT-727. This
6
+ * is the decision that turns them on, and it is a decision rather than a
7
+ * capability check: it can say no, and when it says no the answer has to reach
8
+ * the person about to press send.
9
+ *
10
+ * ## Every member, or nobody
11
+ *
12
+ * A message is sealed only when every member of the conversation has a key this
13
+ * client is willing to use. One member without one is not a reason to seal for
14
+ * the rest — they would be unable to read a conversation they are in, silently,
15
+ * and the sender would have no idea.
16
+ *
17
+ * A member whose key *changed* counts as not having one. That is the refusal
18
+ * from GRYT-726 arriving where it matters: nothing is encrypted to a key this
19
+ * client has decided not to trust, and nothing falls back to plaintext without
20
+ * saying so either.
21
+ *
22
+ * ## Saying no out loud
23
+ *
24
+ * {@link SealDecision} carries who is missing and why. A composer that quietly
25
+ * sends in the clear because somebody has not updated their client is the exact
26
+ * failure this whole design exists to avoid, and it is invisible from the
27
+ * outside — the message sends, it arrives, it reads normally.
28
+ */
29
+ import { openMessage, sealMessage, } from "./message-keys.js";
30
+ /**
31
+ * Can this conversation be sealed, and to whom.
32
+ *
33
+ * `self` is included in the recipients, because a sender who cannot read their
34
+ * own message back has sent something they will look at tomorrow and find
35
+ * empty. `sealMessage` refuses a recipient list without them for that reason;
36
+ * this is where they are put in.
37
+ */
38
+ export function decideSealing({ members, self, }) {
39
+ if (!self) {
40
+ // No key of our own means nothing to seal with, which is a device that has
41
+ // not finished joining rather than a problem with anybody else.
42
+ return { kind: "plaintext", blockedBy: [] };
43
+ }
44
+ const recipients = [
45
+ { memberId: self.memberId, publicKey: self.publicKey },
46
+ ];
47
+ const blockedBy = [];
48
+ for (const member of members) {
49
+ const decision = member.keyState?.decision;
50
+ if (!decision || decision.kind === "none") {
51
+ blockedBy.push({ memberId: member.memberId, reason: "no-key" });
52
+ continue;
53
+ }
54
+ if (decision.kind === "unusable") {
55
+ blockedBy.push({ memberId: member.memberId, reason: "unusable" });
56
+ continue;
57
+ }
58
+ if (decision.kind === "changed") {
59
+ // The refusal from GRYT-726, arriving where it costs something. Falling
60
+ // back to plaintext here is the honest answer; encrypting to the new key
61
+ // would be pretending the change was fine.
62
+ blockedBy.push({ memberId: member.memberId, reason: "changed" });
63
+ continue;
64
+ }
65
+ recipients.push({
66
+ memberId: member.memberId,
67
+ publicKey: decision.verified.dmPublicKey,
68
+ });
69
+ }
70
+ if (blockedBy.length > 0)
71
+ return { kind: "plaintext", blockedBy };
72
+ return { kind: "seal", recipients };
73
+ }
74
+ /**
75
+ * Seal a message for a conversation, or say why it cannot be.
76
+ *
77
+ * Returns the envelope as the string that goes on the wire. Null means send it
78
+ * in the clear — and a caller that ignores which of the two it got is back to
79
+ * sending plaintext without telling anybody.
80
+ */
81
+ export async function sealForConversation({ plaintext, conversationId, senderKeys, decision, attachments, }) {
82
+ if (decision.kind !== "seal")
83
+ return null;
84
+ const sealed = await sealMessage({
85
+ plaintext,
86
+ conversationId,
87
+ senderKeys,
88
+ recipients: decision.recipients,
89
+ attachments,
90
+ });
91
+ return JSON.stringify(sealed);
92
+ }
93
+ /**
94
+ * Read one back.
95
+ *
96
+ * Null when there is no wrapped key for this member — somebody who joined after
97
+ * it was sent — which a client draws as a message it cannot read rather than as
98
+ * an error. Anything else throws, because a key that is present and does not
99
+ * open means tampering or the wrong conversation, and an empty bubble would
100
+ * hide it.
101
+ */
102
+ export async function openForConversation({ sealed, conversationId, memberId, recipientKeys, }) {
103
+ let envelope;
104
+ try {
105
+ envelope = JSON.parse(sealed);
106
+ }
107
+ catch {
108
+ throw new Error("That message is not a sealed envelope.");
109
+ }
110
+ return openMessage({ sealed: envelope, conversationId, memberId, recipientKeys });
111
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Saying that a DM key and an identity key belong to the same person (GRYT-720).
3
+ *
4
+ * `dm-keys.ts` derives the key a message is encrypted to. Nothing says whose it
5
+ * is, and a key handed over by a server that could have made it up is worth
6
+ * nothing — a server that wanted to read a conversation would give each side its
7
+ * own key and relay.
8
+ *
9
+ * This is one link of the chain that answers that: a short JWT, signed by the
10
+ * per-server identity key, saying "this DM public key is mine, on this server".
11
+ *
12
+ * ## What it proves, exactly
13
+ *
14
+ * That whoever holds the identity key also chose this DM key. Nothing else. In
15
+ * particular it does **not** say whose identity key it is — the public half
16
+ * rides in the header, so a server can mint a keypair and sign a perfectly
17
+ * valid binding with it.
18
+ *
19
+ * That is not a hole in this file, it is where the problem actually lives.
20
+ * Nothing verifiable in band can say who a key belongs to; the regress stops at
21
+ * something pinned earlier or something compared out of band, and at nothing
22
+ * else. What this buys is that the two keys are now one thing to substitute
23
+ * instead of two, and the identity key is the one the server challenged at join
24
+ * — so a server handing out a forged binding is contradicting a proof it
25
+ * verified itself, in front of every member at once.
26
+ *
27
+ * The caller pins {@link VerifiedDmKeyBinding.identityThumbprint}. That is the
28
+ * part that means something, and `server-pins.ts` already does the same three
29
+ * moves for server keys: pin on first sight, detect a change, refuse it.
30
+ *
31
+ * ## Why the key is inside the signed statement
32
+ *
33
+ * A server storing a DM key and a signature as two fields could serve one
34
+ * person's key with another's signature, and a client checking them separately
35
+ * might not notice. There is one field: the binding. The key is read out of it
36
+ * after the signature verifies, or it is not read at all.
37
+ */
38
+ import { type IdentityScope } from "./scope.js";
39
+ export interface VerifiedDmKeyBinding {
40
+ /** The X25519 public key, raw bytes, once the signature has been checked. */
41
+ dmPublicKey: Uint8Array<ArrayBuffer>;
42
+ /**
43
+ * The identity key that signed this, as a JWK thumbprint.
44
+ *
45
+ * **This is the thing to pin.** Everything else in here is a statement by
46
+ * whoever holds that key, and is worth exactly what the key is worth.
47
+ */
48
+ identityThumbprint: string;
49
+ /** The scope the binding claims, already checked against the expected one. */
50
+ scope: IdentityScope;
51
+ /** When it was signed, seconds since the epoch. */
52
+ signedAt: number;
53
+ }
54
+ /**
55
+ * Sign the statement.
56
+ *
57
+ * There is no expiry. The DM key is derived from the seed and the scope, so it
58
+ * does not roll and a binding does not go stale — and an expiry a client cannot
59
+ * renew while offline would make old messages unreadable for a reason that has
60
+ * nothing to do with anybody's keys. `signedAt` is there so a verifier can
61
+ * prefer the newer of two bindings if one ever does change, which is a
62
+ * different question from whether this one is still good.
63
+ */
64
+ export declare function signDmKeyBinding({ dmPublicKey, scope, identityPrivateKey, identityPublicJwk, now, }: {
65
+ dmPublicKey: Uint8Array;
66
+ scope: IdentityScope;
67
+ /**
68
+ * A `CryptoKey`, or a function that signs bytes with the identity key.
69
+ *
70
+ * Two shapes because the two clients hold the key differently: the desktop
71
+ * has a WebCrypto handle, and React Native has raw bytes and a curve library
72
+ * (GRYT-733). Verifying is pure and shared — every client does it for every
73
+ * peer — while signing happens once, with your own key, and is the one place
74
+ * the platforms genuinely differ.
75
+ */
76
+ identityPrivateKey: CryptoKey | ((bytes: Uint8Array) => Promise<Uint8Array>);
77
+ /** Rides in the header, so a verifier that has never seen it can check. */
78
+ identityPublicJwk: JsonWebKey;
79
+ now?: number;
80
+ }): Promise<string>;
81
+ /**
82
+ * Check a binding, and refuse it rather than returning something partly checked.
83
+ *
84
+ * Throws on anything wrong. There is no "probably fine" here: a caller that got
85
+ * a value back has a DM key whose signature verified under the thumbprint it was
86
+ * handed, and a caller that did not has nothing to think about.
87
+ *
88
+ * `expectedScope` is required. Without it a binding signed for one server can be
89
+ * replayed by another, which is the cheapest attack available to any operator
90
+ * who can see a member list — and the scope is the one thing the verifier
91
+ * already knows for certain, because it is the server it is talking to.
92
+ */
93
+ export declare function verifyDmKeyBinding(binding: string, expectedScope: IdentityScope): Promise<VerifiedDmKeyBinding>;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Saying that a DM key and an identity key belong to the same person (GRYT-720).
3
+ *
4
+ * `dm-keys.ts` derives the key a message is encrypted to. Nothing says whose it
5
+ * is, and a key handed over by a server that could have made it up is worth
6
+ * nothing — a server that wanted to read a conversation would give each side its
7
+ * own key and relay.
8
+ *
9
+ * This is one link of the chain that answers that: a short JWT, signed by the
10
+ * per-server identity key, saying "this DM public key is mine, on this server".
11
+ *
12
+ * ## What it proves, exactly
13
+ *
14
+ * That whoever holds the identity key also chose this DM key. Nothing else. In
15
+ * particular it does **not** say whose identity key it is — the public half
16
+ * rides in the header, so a server can mint a keypair and sign a perfectly
17
+ * valid binding with it.
18
+ *
19
+ * That is not a hole in this file, it is where the problem actually lives.
20
+ * Nothing verifiable in band can say who a key belongs to; the regress stops at
21
+ * something pinned earlier or something compared out of band, and at nothing
22
+ * else. What this buys is that the two keys are now one thing to substitute
23
+ * instead of two, and the identity key is the one the server challenged at join
24
+ * — so a server handing out a forged binding is contradicting a proof it
25
+ * verified itself, in front of every member at once.
26
+ *
27
+ * The caller pins {@link VerifiedDmKeyBinding.identityThumbprint}. That is the
28
+ * part that means something, and `server-pins.ts` already does the same three
29
+ * moves for server keys: pin on first sight, detect a change, refuse it.
30
+ *
31
+ * ## Why the key is inside the signed statement
32
+ *
33
+ * A server storing a DM key and a signature as two fields could serve one
34
+ * person's key with another's signature, and a client checking them separately
35
+ * might not notice. There is one field: the binding. The key is read out of it
36
+ * after the signature verifies, or it is not read at all.
37
+ */
38
+ /*
39
+ * The `.ts` is for Node's type stripping, which `check-dm-key-binding.mjs` runs
40
+ * this file through and which does no extension inference. `message-keys.ts`
41
+ * carries the same one for the same reason; `dm-keys.ts` does not, because its
42
+ * import from here is type-only and erases.
43
+ */
44
+ import { p256 } from "@noble/curves/nist.js";
45
+ import { sha256 } from "@noble/hashes/sha2.js";
46
+ import { base64Url, base64UrlDecode } from "./base64.js";
47
+ import { asIdentityScope } from "./scope.js";
48
+ import { jwkThumbprint } from "./thumbprint.js";
49
+ /**
50
+ * The `iss` a binding carries.
51
+ *
52
+ * Constant rather than the signer's own id, because a binding is not addressed
53
+ * to a server and has no subject to name. What it is *for* is the thing worth
54
+ * writing down, so a JWT that arrives on this path and says something else is
55
+ * refused rather than read hopefully.
56
+ */
57
+ const BINDING_ISSUER = "gryt:dm-key";
58
+ /**
59
+ * A JWK's public point as the uncompressed bytes the curve library takes.
60
+ *
61
+ * `0x04`, then x, then y — the same layout `identity-seed.ts` slices apart when
62
+ * it builds a JWK from a derived key, put back together.
63
+ */
64
+ function jwkToPoint(jwk) {
65
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256") {
66
+ throw new Error("A DM key binding is signed with a P-256 key.");
67
+ }
68
+ if (typeof jwk.x !== "string" || typeof jwk.y !== "string") {
69
+ throw new Error("That DM key binding's key has no coordinates.");
70
+ }
71
+ const x = base64UrlDecode(jwk.x);
72
+ const y = base64UrlDecode(jwk.y);
73
+ if (x.length !== 32 || y.length !== 32) {
74
+ throw new Error("A P-256 coordinate is 32 bytes.");
75
+ }
76
+ const point = new Uint8Array(65);
77
+ point[0] = 0x04;
78
+ point.set(x, 1);
79
+ point.set(y, 33);
80
+ return point;
81
+ }
82
+ function utf8(value) {
83
+ return new TextEncoder().encode(value);
84
+ }
85
+ /**
86
+ * Sign the statement.
87
+ *
88
+ * There is no expiry. The DM key is derived from the seed and the scope, so it
89
+ * does not roll and a binding does not go stale — and an expiry a client cannot
90
+ * renew while offline would make old messages unreadable for a reason that has
91
+ * nothing to do with anybody's keys. `signedAt` is there so a verifier can
92
+ * prefer the newer of two bindings if one ever does change, which is a
93
+ * different question from whether this one is still good.
94
+ */
95
+ export async function signDmKeyBinding({ dmPublicKey, scope, identityPrivateKey, identityPublicJwk, now = Math.floor(Date.now() / 1000), }) {
96
+ const header = {
97
+ alg: "ES256",
98
+ typ: "JWT",
99
+ jwk: identityPublicJwk,
100
+ };
101
+ const payload = {
102
+ iss: BINDING_ISSUER,
103
+ scope,
104
+ dm: base64Url(dmPublicKey),
105
+ iat: now,
106
+ };
107
+ const signingInput = `${base64Url(utf8(JSON.stringify(header)))}.${base64Url(utf8(JSON.stringify(payload)))}`;
108
+ const bytes = utf8(signingInput);
109
+ const signature = typeof identityPrivateKey === "function"
110
+ ? await identityPrivateKey(bytes)
111
+ : new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, identityPrivateKey, bytes));
112
+ return `${signingInput}.${base64Url(signature)}`;
113
+ }
114
+ /**
115
+ * Check a binding, and refuse it rather than returning something partly checked.
116
+ *
117
+ * Throws on anything wrong. There is no "probably fine" here: a caller that got
118
+ * a value back has a DM key whose signature verified under the thumbprint it was
119
+ * handed, and a caller that did not has nothing to think about.
120
+ *
121
+ * `expectedScope` is required. Without it a binding signed for one server can be
122
+ * replayed by another, which is the cheapest attack available to any operator
123
+ * who can see a member list — and the scope is the one thing the verifier
124
+ * already knows for certain, because it is the server it is talking to.
125
+ */
126
+ export async function verifyDmKeyBinding(binding, expectedScope) {
127
+ const parts = binding.split(".");
128
+ if (parts.length !== 3) {
129
+ throw new Error("A DM key binding is a compact JWT with three parts.");
130
+ }
131
+ let header;
132
+ let payload;
133
+ try {
134
+ header = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0])));
135
+ payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1])));
136
+ }
137
+ catch {
138
+ throw new Error("That DM key binding is not readable.");
139
+ }
140
+ // Pinned rather than read off the header. `alg: "none"` is the oldest JWT bug
141
+ // there is, and every softer version of it — accepting HS256 and verifying
142
+ // the signature with the public key as the HMAC secret — starts with taking
143
+ // the algorithm from the attacker.
144
+ if (header.alg !== "ES256" || header.typ !== "JWT") {
145
+ throw new Error("A DM key binding is ES256, and this one says otherwise.");
146
+ }
147
+ const jwk = header.jwk;
148
+ if (!jwk || typeof jwk !== "object") {
149
+ throw new Error("That DM key binding carries no key to check it with.");
150
+ }
151
+ if (jwk.d !== undefined) {
152
+ throw new Error("That DM key binding carries private key material.");
153
+ }
154
+ if (payload.iss !== BINDING_ISSUER) {
155
+ throw new Error(`A DM key binding is issued by ${BINDING_ISSUER}.`);
156
+ }
157
+ if (payload.scope !== expectedScope) {
158
+ // Replay from another server. The binding is perfectly valid there.
159
+ throw new Error("That DM key binding was signed for a different server.");
160
+ }
161
+ if (typeof payload.dm !== "string" || typeof payload.iat !== "number") {
162
+ throw new Error("That DM key binding is missing a key or a time.");
163
+ }
164
+ /*
165
+ * Verified with the curve library rather than the platform (GRYT-733).
166
+ *
167
+ * `crypto.subtle` is not on React Native and this file has to run there
168
+ * unchanged. The signature is the same either way: ES256 is P-256 over a
169
+ * SHA-256 digest with a raw sixty-four byte `r || s`, which is what WebCrypto
170
+ * emits and what `p256.verify` takes.
171
+ */
172
+ const publicKey = jwkToPoint(jwk);
173
+ const signature = base64UrlDecode(parts[2]);
174
+ if (signature.length !== 64) {
175
+ throw new Error("A DM key binding's signature is 64 bytes.");
176
+ }
177
+ /*
178
+ * `lowS: false`, and this is not a relaxation.
179
+ *
180
+ * ECDSA has two valid signatures for every message — `s` and `order - s` —
181
+ * and noble refuses the high one by default, because for a blockchain a
182
+ * signature that can be rewritten while staying valid is a transaction that
183
+ * can be replayed under a second id. Nothing here is identified by its
184
+ * signature.
185
+ *
186
+ * WebCrypto does not normalise, JOSE does not require it, and roughly half of
187
+ * all ES256 signatures come out high. Leaving the default on would have
188
+ * rejected about half of every client's bindings, at random, with the message
189
+ * that the signature did not check out — and the other half would have worked
190
+ * perfectly, which is the shape of bug that survives a lot of testing.
191
+ */
192
+ const ok = p256.verify(signature, sha256(utf8(`${parts[0]}.${parts[1]}`)), publicKey, { prehash: false, lowS: false });
193
+ if (!ok) {
194
+ throw new Error("That DM key binding's signature does not check out.");
195
+ }
196
+ const dmPublicKey = base64UrlDecode(payload.dm);
197
+ // X25519 public keys are 32 bytes. Anything else is not one, and passing it
198
+ // to the curve library would be the place that found out.
199
+ if (dmPublicKey.length !== 32) {
200
+ throw new Error(`A DM public key is 32 bytes, and this one is ${dmPublicKey.length}.`);
201
+ }
202
+ return {
203
+ dmPublicKey,
204
+ identityThumbprint: jwkThumbprint(jwk),
205
+ scope: asIdentityScope(payload.scope),
206
+ signedAt: payload.iat,
207
+ };
208
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The key a direct message is encrypted to (GRYT-709).
3
+ *
4
+ * Separate from the identity key on purpose, and not because it is tidier. The
5
+ * identity key is ECDSA P-256, which signs and cannot do key agreement — there
6
+ * is no operation that turns two ECDSA keys into a shared secret. Encryption
7
+ * needs a curve built for agreement, so this is X25519 and it is a second key.
8
+ *
9
+ * ## Nothing secret ever leaves the device
10
+ *
11
+ * Only {@link dmPublicKey} is published. The private half is derived here, used
12
+ * here, and never sent anywhere — not to the server, not encrypted, not as part
13
+ * of a backup. A server that wanted to read a conversation would have to obtain
14
+ * something that has never been transmitted.
15
+ *
16
+ * What *is* backed up is the seed, which the 24-word phrase already carries. So
17
+ * restoring an identity restores the ability to read old messages, without
18
+ * anything about the messages being stored anywhere but the server that already
19
+ * has them.
20
+ *
21
+ * ## One key per server, like the identity key
22
+ *
23
+ * `identity-seed.ts` derives a separate key for each server so that two of them
24
+ * cannot tell they are talking to the same person. That property is worth
25
+ * exactly as much here, and would be undone by a single DM key shared across
26
+ * servers — so the same scope goes into the derivation.
27
+ *
28
+ * **The scope, not the address (GRYT-719).** `identityScopeFor` gives the
29
+ * server's lineage id, and only falls back to the address for a server that
30
+ * proved nothing. Deriving from the address instead would give a server on a
31
+ * LAN address and a tunnel two DM keys, and would change the key whenever a
32
+ * port is taken or a lease moves. For the identity key that bug meant arriving
33
+ * as a stranger, which GRYT-257 fixed and which is at least visible. Here it
34
+ * would mean every message ever encrypted to the old key is unreadable, with
35
+ * nothing logged. `IdentityScope` is branded so the wrong string does not
36
+ * typecheck.
37
+ *
38
+ * The consequence is that a conversation is bound to the server it happens on,
39
+ * which is what `useDirectMessages.ts` and `conversations.ts` already say about
40
+ * DMs in plaintext. Encryption does not change the shape, it enforces it.
41
+ *
42
+ * ## What this file does not do
43
+ *
44
+ * It derives a keypair and computes a shared secret. It does not publish the
45
+ * public key, does not encrypt a message, and does not know what a conversation
46
+ * is. Wrapping a per-message key for each member, and the certificate that says
47
+ * whose public key is whose, are the parts that follow — and the certificate is
48
+ * the one that decides whether any of this is worth anything, because a shared
49
+ * secret with a key the server chose for you protects nothing.
50
+ */
51
+ import type { IdentityScope } from "./scope.js";
52
+ export interface DmKeyPair {
53
+ /** Never leaves this device. Not sent, not backed up, not logged. */
54
+ privateKey: Uint8Array;
55
+ /** The half that is published, so others can encrypt to you. */
56
+ publicKey: Uint8Array;
57
+ }
58
+ /**
59
+ * The DM keypair this seed gives for one server.
60
+ *
61
+ * Deterministic: the same seed and scope give the same keypair on any device,
62
+ * which is what makes the recovery phrase enough to read old conversations
63
+ * again — and, because the scope outlives an address change, what makes them
64
+ * still readable after the server moves.
65
+ *
66
+ * Raw bytes rather than a `CryptoKey`, because WebCrypto's X25519 support is
67
+ * younger than the browsers Gryt runs on and the agreement below is done by the
68
+ * curve library anyway. There is nothing to gain from importing a key into an
69
+ * API that is not going to perform the operation.
70
+ */
71
+ export declare function deriveDmKeyPair(seed: Uint8Array, scope: IdentityScope): DmKeyPair;
72
+ /** The public half alone, for the cases that should not touch the private one. */
73
+ export declare function dmPublicKey(seed: Uint8Array, scope: IdentityScope): Uint8Array;
74
+ /**
75
+ * The secret two people share, from one private half and one public half.
76
+ *
77
+ * Run through HKDF rather than used raw. The X25519 output is a curve point's
78
+ * x-coordinate, which is not uniformly distributed over 32 bytes, and a key
79
+ * derivation function is what turns it into something safe to use as one. The
80
+ * conversation id goes in as `info`, so the same pair of people talking in two
81
+ * conversations do not derive the same key in both.
82
+ *
83
+ * **This is not enough on its own, and the gap is not in the maths.** Agreement
84
+ * with the wrong public key succeeds exactly as well as agreement with the right
85
+ * one — both produce a perfectly good secret. Whether `theirPublicKey` belongs
86
+ * to the person named beside it is the question the certificate answers, and
87
+ * until that exists a caller is trusting the server for it.
88
+ */
89
+ export declare function dmSharedSecret(privateKey: Uint8Array, theirPublicKey: Uint8Array, conversationId: string): Uint8Array;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The key a direct message is encrypted to (GRYT-709).
3
+ *
4
+ * Separate from the identity key on purpose, and not because it is tidier. The
5
+ * identity key is ECDSA P-256, which signs and cannot do key agreement — there
6
+ * is no operation that turns two ECDSA keys into a shared secret. Encryption
7
+ * needs a curve built for agreement, so this is X25519 and it is a second key.
8
+ *
9
+ * ## Nothing secret ever leaves the device
10
+ *
11
+ * Only {@link dmPublicKey} is published. The private half is derived here, used
12
+ * here, and never sent anywhere — not to the server, not encrypted, not as part
13
+ * of a backup. A server that wanted to read a conversation would have to obtain
14
+ * something that has never been transmitted.
15
+ *
16
+ * What *is* backed up is the seed, which the 24-word phrase already carries. So
17
+ * restoring an identity restores the ability to read old messages, without
18
+ * anything about the messages being stored anywhere but the server that already
19
+ * has them.
20
+ *
21
+ * ## One key per server, like the identity key
22
+ *
23
+ * `identity-seed.ts` derives a separate key for each server so that two of them
24
+ * cannot tell they are talking to the same person. That property is worth
25
+ * exactly as much here, and would be undone by a single DM key shared across
26
+ * servers — so the same scope goes into the derivation.
27
+ *
28
+ * **The scope, not the address (GRYT-719).** `identityScopeFor` gives the
29
+ * server's lineage id, and only falls back to the address for a server that
30
+ * proved nothing. Deriving from the address instead would give a server on a
31
+ * LAN address and a tunnel two DM keys, and would change the key whenever a
32
+ * port is taken or a lease moves. For the identity key that bug meant arriving
33
+ * as a stranger, which GRYT-257 fixed and which is at least visible. Here it
34
+ * would mean every message ever encrypted to the old key is unreadable, with
35
+ * nothing logged. `IdentityScope` is branded so the wrong string does not
36
+ * typecheck.
37
+ *
38
+ * The consequence is that a conversation is bound to the server it happens on,
39
+ * which is what `useDirectMessages.ts` and `conversations.ts` already say about
40
+ * DMs in plaintext. Encryption does not change the shape, it enforces it.
41
+ *
42
+ * ## What this file does not do
43
+ *
44
+ * It derives a keypair and computes a shared secret. It does not publish the
45
+ * public key, does not encrypt a message, and does not know what a conversation
46
+ * is. Wrapping a per-message key for each member, and the certificate that says
47
+ * whose public key is whose, are the parts that follow — and the certificate is
48
+ * the one that decides whether any of this is worth anything, because a shared
49
+ * secret with a key the server chose for you protects nothing.
50
+ */
51
+ import { x25519 } from "@noble/curves/ed25519.js";
52
+ import { hkdf } from "@noble/hashes/hkdf.js";
53
+ import { sha256 } from "@noble/hashes/sha2.js";
54
+ /**
55
+ * Domain separator, and why it is not the identity one.
56
+ *
57
+ * `identity-seed.ts` uses `gryt-identity-v1`. Deriving both keys from one seed
58
+ * under the same label would hand the same 32 bytes to two different
59
+ * algorithms, which is the kind of reuse that turns two safe primitives into
60
+ * one unsafe system. A different label makes the two keys independent: knowing
61
+ * either tells you nothing about the other, because HKDF's outputs for
62
+ * different `info` are unrelated to anyone without the seed.
63
+ *
64
+ * Versioned for the same reason the identity one is. Changing this string
65
+ * changes every DM key that has ever existed, which would make every message
66
+ * already sent unreadable — so a `v2` arrives with a migration or not at all.
67
+ */
68
+ const DERIVATION_SALT = "gryt-dm-v1";
69
+ /**
70
+ * Exactly 32, and this is where X25519 differs from the P-256 path next door.
71
+ *
72
+ * `deriveLocalKeyPair` takes 48 bytes and reduces them modulo the curve order,
73
+ * because a P-256 scalar has to land in a range that 32 uniform bytes overshoot
74
+ * slightly, and the excess makes low values fractionally likelier.
75
+ *
76
+ * X25519 has no such range. Any 32 bytes is a valid secret — the algorithm
77
+ * clamps the bits it cares about itself, which is part of its definition rather
78
+ * than something a caller does. So taking more than 32 and reducing would be
79
+ * ceremony that copies the shape of the other function without its reason.
80
+ */
81
+ const SECRET_BYTES = 32;
82
+ function utf8(value) {
83
+ return new TextEncoder().encode(value);
84
+ }
85
+ /**
86
+ * Refuse a seed this obviously cannot be right.
87
+ *
88
+ * The mirror of `assertUsableSeed` in `identity-seed.ts`, and here for the same
89
+ * reason: a stub or a platform handing back a constant would give every device
90
+ * the same DM key, and every user would be able to read every conversation. A
91
+ * real seed being all one byte has a probability around 2^-248, so nothing
92
+ * legitimate is being turned away.
93
+ */
94
+ function assertUsableSeed(seed) {
95
+ if (seed.length !== SECRET_BYTES) {
96
+ throw new Error(`A seed is ${SECRET_BYTES} bytes, not ${seed.length}.`);
97
+ }
98
+ if (seed.every((byte) => byte === seed[0])) {
99
+ throw new Error("That seed is a single repeated byte, which is not a seed.");
100
+ }
101
+ }
102
+ /**
103
+ * The DM keypair this seed gives for one server.
104
+ *
105
+ * Deterministic: the same seed and scope give the same keypair on any device,
106
+ * which is what makes the recovery phrase enough to read old conversations
107
+ * again — and, because the scope outlives an address change, what makes them
108
+ * still readable after the server moves.
109
+ *
110
+ * Raw bytes rather than a `CryptoKey`, because WebCrypto's X25519 support is
111
+ * younger than the browsers Gryt runs on and the agreement below is done by the
112
+ * curve library anyway. There is nothing to gain from importing a key into an
113
+ * API that is not going to perform the operation.
114
+ */
115
+ export function deriveDmKeyPair(seed, scope) {
116
+ assertUsableSeed(seed);
117
+ const privateKey = hkdf(sha256, seed, utf8(DERIVATION_SALT), utf8(scope), SECRET_BYTES);
118
+ return { privateKey, publicKey: x25519.getPublicKey(privateKey) };
119
+ }
120
+ /** The public half alone, for the cases that should not touch the private one. */
121
+ export function dmPublicKey(seed, scope) {
122
+ return deriveDmKeyPair(seed, scope).publicKey;
123
+ }
124
+ /**
125
+ * The secret two people share, from one private half and one public half.
126
+ *
127
+ * Run through HKDF rather than used raw. The X25519 output is a curve point's
128
+ * x-coordinate, which is not uniformly distributed over 32 bytes, and a key
129
+ * derivation function is what turns it into something safe to use as one. The
130
+ * conversation id goes in as `info`, so the same pair of people talking in two
131
+ * conversations do not derive the same key in both.
132
+ *
133
+ * **This is not enough on its own, and the gap is not in the maths.** Agreement
134
+ * with the wrong public key succeeds exactly as well as agreement with the right
135
+ * one — both produce a perfectly good secret. Whether `theirPublicKey` belongs
136
+ * to the person named beside it is the question the certificate answers, and
137
+ * until that exists a caller is trusting the server for it.
138
+ */
139
+ export function dmSharedSecret(privateKey, theirPublicKey, conversationId) {
140
+ const shared = x25519.getSharedSecret(privateKey, theirPublicKey);
141
+ return hkdf(sha256, shared, utf8(`${DERIVATION_SALT}-shared`), utf8(conversationId), SECRET_BYTES);
142
+ }