@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.
- package/LICENSE +677 -0
- package/README.md +77 -0
- package/dist/attachments.d.ts +57 -0
- package/dist/attachments.js +99 -0
- package/dist/base64.d.ts +27 -0
- package/dist/base64.js +66 -0
- package/dist/comparison-code.d.ts +58 -0
- package/dist/comparison-code.js +105 -0
- package/dist/conversation-encryption.d.ts +116 -0
- package/dist/conversation-encryption.js +111 -0
- package/dist/dm-key-binding.d.ts +93 -0
- package/dist/dm-key-binding.js +208 -0
- package/dist/dm-keys.d.ts +89 -0
- package/dist/dm-keys.js +142 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +44 -0
- package/dist/member-keys.d.ts +63 -0
- package/dist/member-keys.js +51 -0
- package/dist/message-keys.d.ts +153 -0
- package/dist/message-keys.js +203 -0
- package/dist/peer-keys.d.ts +155 -0
- package/dist/peer-keys.js +152 -0
- package/dist/scope.d.ts +33 -0
- package/dist/scope.js +14 -0
- package/dist/thumbprint.d.ts +17 -0
- package/dist/thumbprint.js +28 -0
- package/package.json +45 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message encryption for Gryt.
|
|
3
|
+
*
|
|
4
|
+
* Everything the desktop client and the mobile app both need to derive message
|
|
5
|
+
* keys, say a key is theirs, seal a message to a conversation, decide whether a
|
|
6
|
+
* peer's key is the one seen before, and let two people check that out of band.
|
|
7
|
+
*
|
|
8
|
+
* ## One implementation, on purpose
|
|
9
|
+
*
|
|
10
|
+
* This started as seven files in the client and a plan to port them. Two ports
|
|
11
|
+
* of one envelope is a pair of clients that send each other messages nobody can
|
|
12
|
+
* read, with the sender looking at the text they typed either way — and no
|
|
13
|
+
* amount of test vectors between two implementations is as good as not having
|
|
14
|
+
* two. GRYT-733 made the code platform-free so this could exist.
|
|
15
|
+
*
|
|
16
|
+
* ## Nothing here touches a platform
|
|
17
|
+
*
|
|
18
|
+
* No `crypto.subtle`, which React Native does not have. No storage: pins go
|
|
19
|
+
* through a {@link PeerPinStore} the caller supplies, because the desktop has
|
|
20
|
+
* `localStorage` and a phone has something asynchronous. No network, no React,
|
|
21
|
+
* no config.
|
|
22
|
+
*
|
|
23
|
+
* The two exceptions are named where they are: `crypto.getRandomValues`, which
|
|
24
|
+
* every target has, and signing a key binding, which takes either a WebCrypto
|
|
25
|
+
* key or a function because that is the one place the platforms genuinely hold
|
|
26
|
+
* a key differently.
|
|
27
|
+
*
|
|
28
|
+
* ## What it does not do
|
|
29
|
+
*
|
|
30
|
+
* Forward secrecy. A DM key is derived from the seed and never moves, so a seed
|
|
31
|
+
* that leaks reads every message ever sent to it. Signal and Matrix ratchet;
|
|
32
|
+
* this does not. That is GRYT-754, and it is a different protocol rather than a
|
|
33
|
+
* setting.
|
|
34
|
+
*/
|
|
35
|
+
export * from "./attachments.js";
|
|
36
|
+
export * from "./comparison-code.js";
|
|
37
|
+
export * from "./conversation-encryption.js";
|
|
38
|
+
export * from "./dm-key-binding.js";
|
|
39
|
+
export * from "./dm-keys.js";
|
|
40
|
+
export * from "./member-keys.js";
|
|
41
|
+
export * from "./message-keys.js";
|
|
42
|
+
export * from "./peer-keys.js";
|
|
43
|
+
export * from "./scope.js";
|
|
44
|
+
export * from "./thumbprint.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message encryption for Gryt.
|
|
3
|
+
*
|
|
4
|
+
* Everything the desktop client and the mobile app both need to derive message
|
|
5
|
+
* keys, say a key is theirs, seal a message to a conversation, decide whether a
|
|
6
|
+
* peer's key is the one seen before, and let two people check that out of band.
|
|
7
|
+
*
|
|
8
|
+
* ## One implementation, on purpose
|
|
9
|
+
*
|
|
10
|
+
* This started as seven files in the client and a plan to port them. Two ports
|
|
11
|
+
* of one envelope is a pair of clients that send each other messages nobody can
|
|
12
|
+
* read, with the sender looking at the text they typed either way — and no
|
|
13
|
+
* amount of test vectors between two implementations is as good as not having
|
|
14
|
+
* two. GRYT-733 made the code platform-free so this could exist.
|
|
15
|
+
*
|
|
16
|
+
* ## Nothing here touches a platform
|
|
17
|
+
*
|
|
18
|
+
* No `crypto.subtle`, which React Native does not have. No storage: pins go
|
|
19
|
+
* through a {@link PeerPinStore} the caller supplies, because the desktop has
|
|
20
|
+
* `localStorage` and a phone has something asynchronous. No network, no React,
|
|
21
|
+
* no config.
|
|
22
|
+
*
|
|
23
|
+
* The two exceptions are named where they are: `crypto.getRandomValues`, which
|
|
24
|
+
* every target has, and signing a key binding, which takes either a WebCrypto
|
|
25
|
+
* key or a function because that is the one place the platforms genuinely hold
|
|
26
|
+
* a key differently.
|
|
27
|
+
*
|
|
28
|
+
* ## What it does not do
|
|
29
|
+
*
|
|
30
|
+
* Forward secrecy. A DM key is derived from the seed and never moves, so a seed
|
|
31
|
+
* that leaks reads every message ever sent to it. Signal and Matrix ratchet;
|
|
32
|
+
* this does not. That is GRYT-754, and it is a different protocol rather than a
|
|
33
|
+
* setting.
|
|
34
|
+
*/
|
|
35
|
+
export * from "./attachments.js";
|
|
36
|
+
export * from "./comparison-code.js";
|
|
37
|
+
export * from "./conversation-encryption.js";
|
|
38
|
+
export * from "./dm-key-binding.js";
|
|
39
|
+
export * from "./dm-keys.js";
|
|
40
|
+
export * from "./member-keys.js";
|
|
41
|
+
export * from "./message-keys.js";
|
|
42
|
+
export * from "./peer-keys.js";
|
|
43
|
+
export * from "./scope.js";
|
|
44
|
+
export * from "./thumbprint.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a member list does to your pins (GRYT-727).
|
|
3
|
+
*
|
|
4
|
+
* `peer-keys.ts` decides about one binding. This is the policy over a whole
|
|
5
|
+
* list, and the policy is where the mistakes are: pinning on a change instead
|
|
6
|
+
* of on a first sighting turns the design off and nothing on screen looks
|
|
7
|
+
* different, and pinning your own row means a server that rewrites your key
|
|
8
|
+
* gets it pinned by you.
|
|
9
|
+
*
|
|
10
|
+
* Here rather than in the socket package so a check can import it. The socket
|
|
11
|
+
* half of GRYT-727 is one `emit` and one `then`; this is the part with
|
|
12
|
+
* decisions in it, and it kept none of them within reach of a test while it
|
|
13
|
+
* lived behind a Vite alias.
|
|
14
|
+
*/
|
|
15
|
+
import { type PeerKeyDecision, type PeerPinStore } from "./peer-keys.js";
|
|
16
|
+
import type { IdentityScope } from "./scope.js";
|
|
17
|
+
export interface MemberKeyState {
|
|
18
|
+
decision: PeerKeyDecision;
|
|
19
|
+
/** Whether this row is the person running this client. */
|
|
20
|
+
isSelf: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Set only on your own row, and only when it disagrees with what you hold.
|
|
23
|
+
*
|
|
24
|
+
* You know what your key on this server should be, because you derived it, so
|
|
25
|
+
* a member list showing something else under your own id is this server
|
|
26
|
+
* rewriting it (GRYT-727). It is the one check a single person can run with
|
|
27
|
+
* nobody else involved — and it catches only the careless version, because an
|
|
28
|
+
* operator can serve you the truth and everybody else a lie. Combined with
|
|
29
|
+
* keys riding the member list, the lie then has to hold in front of every
|
|
30
|
+
* member at once.
|
|
31
|
+
*/
|
|
32
|
+
ownKeyRewritten?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Work out what to do about every binding in a member list.
|
|
36
|
+
*
|
|
37
|
+
* `first` is pinned here, because that is what trust on first use means and
|
|
38
|
+
* there is nobody to ask. `changed` is returned untouched and never pinned:
|
|
39
|
+
* somebody has to decide, and a client that re-pinned on its own would have
|
|
40
|
+
* thrown away the only protection this design has.
|
|
41
|
+
*/
|
|
42
|
+
export declare function evaluateMemberKeys({ store, scope, ownKey, members, myServerUserId, }: {
|
|
43
|
+
/** Where pins live. See `PeerPinStore` — this package does not decide. */
|
|
44
|
+
store: PeerPinStore;
|
|
45
|
+
scope: IdentityScope;
|
|
46
|
+
/**
|
|
47
|
+
* The DM public key this device uses on this server, from `ownDmPublicKey`.
|
|
48
|
+
*
|
|
49
|
+
* The public half and nothing more. Taking the seed instead would work and
|
|
50
|
+
* would mean the master secret leaving the module that owns the database, to
|
|
51
|
+
* compute a value that module already exposes.
|
|
52
|
+
*
|
|
53
|
+
* Null when it cannot be worked out, which turns the self-check off rather
|
|
54
|
+
* than making it fail.
|
|
55
|
+
*/
|
|
56
|
+
ownKey: Uint8Array | null;
|
|
57
|
+
members: {
|
|
58
|
+
serverUserId: string;
|
|
59
|
+
dmKeyBinding?: string | null;
|
|
60
|
+
}[];
|
|
61
|
+
/** Null before the member list has said which row is yours. */
|
|
62
|
+
myServerUserId: string | null;
|
|
63
|
+
}): Promise<Record<string, MemberKeyState>>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a member list does to your pins (GRYT-727).
|
|
3
|
+
*
|
|
4
|
+
* `peer-keys.ts` decides about one binding. This is the policy over a whole
|
|
5
|
+
* list, and the policy is where the mistakes are: pinning on a change instead
|
|
6
|
+
* of on a first sighting turns the design off and nothing on screen looks
|
|
7
|
+
* different, and pinning your own row means a server that rewrites your key
|
|
8
|
+
* gets it pinned by you.
|
|
9
|
+
*
|
|
10
|
+
* Here rather than in the socket package so a check can import it. The socket
|
|
11
|
+
* half of GRYT-727 is one `emit` and one `then`; this is the part with
|
|
12
|
+
* decisions in it, and it kept none of them within reach of a test while it
|
|
13
|
+
* lived behind a Vite alias.
|
|
14
|
+
*/
|
|
15
|
+
import { base64Url } from "./base64.js";
|
|
16
|
+
import { evaluatePeerKey, pinPeerKey, } from "./peer-keys.js";
|
|
17
|
+
/**
|
|
18
|
+
* Work out what to do about every binding in a member list.
|
|
19
|
+
*
|
|
20
|
+
* `first` is pinned here, because that is what trust on first use means and
|
|
21
|
+
* there is nobody to ask. `changed` is returned untouched and never pinned:
|
|
22
|
+
* somebody has to decide, and a client that re-pinned on its own would have
|
|
23
|
+
* thrown away the only protection this design has.
|
|
24
|
+
*/
|
|
25
|
+
export async function evaluateMemberKeys({ store, scope, ownKey, members, myServerUserId, }) {
|
|
26
|
+
// Derived once rather than per member, and only when there is a row to check
|
|
27
|
+
// it against.
|
|
28
|
+
const mine = ownKey && myServerUserId ? base64Url(ownKey) : null;
|
|
29
|
+
const states = {};
|
|
30
|
+
for (const member of members) {
|
|
31
|
+
const isSelf = member.serverUserId === myServerUserId;
|
|
32
|
+
const decision = await evaluatePeerKey({
|
|
33
|
+
store,
|
|
34
|
+
scope,
|
|
35
|
+
memberId: member.serverUserId,
|
|
36
|
+
binding: member.dmKeyBinding,
|
|
37
|
+
});
|
|
38
|
+
if (decision.kind === "first" && !isSelf) {
|
|
39
|
+
pinPeerKey(store, scope, member.serverUserId, decision.verified);
|
|
40
|
+
}
|
|
41
|
+
const state = { decision, isSelf };
|
|
42
|
+
if (isSelf && mine && decision.kind !== "none") {
|
|
43
|
+
const shown = decision.kind === "unusable" ? null : base64Url(decision.verified.dmPublicKey);
|
|
44
|
+
// An unusable binding on your own row counts too: you published something
|
|
45
|
+
// that verifies, so whatever is being shown is not it.
|
|
46
|
+
state.ownKeyRewritten = shown !== mine;
|
|
47
|
+
}
|
|
48
|
+
states[member.serverUserId] = state;
|
|
49
|
+
}
|
|
50
|
+
return states;
|
|
51
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One key per message, wrapped once per member (GRYT-718).
|
|
3
|
+
*
|
|
4
|
+
* `dm-keys.ts` gives two people a shared secret. Three people do not have one,
|
|
5
|
+
* and a group is what `conversations.ts` calls a conversation with `kind:
|
|
6
|
+
* "group"` — so the shape here is the one GRYT-709 picked: a random key for the
|
|
7
|
+
* message, encrypted once for each member with the secret the sender shares
|
|
8
|
+
* with that member.
|
|
9
|
+
*
|
|
10
|
+
* It scales with the member cap the server already enforces rather than with
|
|
11
|
+
* anything unbounded, and it needs no group ratchet, no key agreement between
|
|
12
|
+
* the members themselves, and nothing kept between messages.
|
|
13
|
+
*
|
|
14
|
+
* ## Membership changes stop being a policy question
|
|
15
|
+
*
|
|
16
|
+
* `conversations.ts` worries about a message "becoming readable by a third
|
|
17
|
+
* because somebody tapped add", and today it answers by refusing to turn a
|
|
18
|
+
* one-to-one into a group. Here the answer is structural: somebody added
|
|
19
|
+
* afterwards has no wrapped key in any message sent before they arrived, so
|
|
20
|
+
* those messages stay unreadable to them. Nothing enforces that; there is
|
|
21
|
+
* simply nothing for them to open.
|
|
22
|
+
*
|
|
23
|
+
* The reverse holds too. Removing somebody does not take back what they could
|
|
24
|
+
* already read, and no design can — they had the key.
|
|
25
|
+
*
|
|
26
|
+
* ## What this is not
|
|
27
|
+
*
|
|
28
|
+
* **Not authenticated as coming from the sender.** Everyone holding the content
|
|
29
|
+
* key can encrypt with it, so any member could produce a message the others
|
|
30
|
+
* decrypt happily. Saying *who wrote it* is a signature with the identity key,
|
|
31
|
+
* and that is not here yet.
|
|
32
|
+
*
|
|
33
|
+
* **Not end-to-end against the server, yet.** Wrapping to a public key is only
|
|
34
|
+
* worth something if the public key belongs to who you think. That is the
|
|
35
|
+
* certificate GRYT-709 describes, and until a peer can check one themselves a
|
|
36
|
+
* caller is trusting the server for it. `dmSharedSecret` says the same thing
|
|
37
|
+
* from the other end.
|
|
38
|
+
*
|
|
39
|
+
* **Not private about who is in the conversation.** The wrapped keys are listed
|
|
40
|
+
* by member id, so a sealed message names its own recipients. The server that
|
|
41
|
+
* stores it already stores the membership, so this gives away nothing it did
|
|
42
|
+
* not have — but it does mean the ciphertext is not anonymous on its own.
|
|
43
|
+
*/
|
|
44
|
+
import type { SealedAttachmentKey } from "./attachments.js";
|
|
45
|
+
import { type DmKeyPair } from "./dm-keys.js";
|
|
46
|
+
export declare const SEALED_MESSAGE_TYPE = "gryt-sealed-message";
|
|
47
|
+
/** One member's copy of the content key. */
|
|
48
|
+
export interface WrappedKey {
|
|
49
|
+
iv: string;
|
|
50
|
+
key: string;
|
|
51
|
+
}
|
|
52
|
+
export interface SealedMessage {
|
|
53
|
+
type: typeof SEALED_MESSAGE_TYPE;
|
|
54
|
+
version: 1;
|
|
55
|
+
/**
|
|
56
|
+
* The sender's DM public key, base64url.
|
|
57
|
+
*
|
|
58
|
+
* Here because a reader needs it to derive the secret that opens their
|
|
59
|
+
* wrapped key, and the sender is not always somebody the reader has looked up
|
|
60
|
+
* — a member can leave. It is *not* proof of who sent this; see the header.
|
|
61
|
+
*/
|
|
62
|
+
sender: string;
|
|
63
|
+
/** The body's nonce. */
|
|
64
|
+
iv: string;
|
|
65
|
+
/** The message, encrypted with the content key. */
|
|
66
|
+
body: string;
|
|
67
|
+
/** Member id to that member's wrapped copy of the content key. */
|
|
68
|
+
keys: Record<string, WrappedKey>;
|
|
69
|
+
/**
|
|
70
|
+
* File id to that file's key and real metadata, encrypted (GRYT-729).
|
|
71
|
+
*
|
|
72
|
+
* Absent on every message sent before attachments could be sealed, and on
|
|
73
|
+
* every message with no files, which is most of them. A reader that finds
|
|
74
|
+
* nothing here and a message that carries `attachments` is looking at a
|
|
75
|
+
* conversation where the files went up in the clear.
|
|
76
|
+
*
|
|
77
|
+
* Encrypted under this message's content key rather than wrapped per member,
|
|
78
|
+
* because the content key is already wrapped per member — doing it twice
|
|
79
|
+
* would be the same secret protected the same way, at N times the size.
|
|
80
|
+
*/
|
|
81
|
+
files?: Record<string, SealedFileKey>;
|
|
82
|
+
}
|
|
83
|
+
/** One file's key and metadata, encrypted under the message's content key. */
|
|
84
|
+
export interface SealedFileKey {
|
|
85
|
+
iv: string;
|
|
86
|
+
/** The JSON of a {@link SealedAttachmentKey}, encrypted. */
|
|
87
|
+
meta: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What came out of a message, once it opened.
|
|
91
|
+
*
|
|
92
|
+
* `attachments` is empty for a message with no files, which is most of them,
|
|
93
|
+
* and for every message sealed before files could be. It is deliberately not
|
|
94
|
+
* optional: a caller that forgets to look at it draws a conversation where
|
|
95
|
+
* files silently do not appear, and an empty object at least makes the loop
|
|
96
|
+
* over it run.
|
|
97
|
+
*/
|
|
98
|
+
export interface OpenedMessage {
|
|
99
|
+
text: string;
|
|
100
|
+
attachments: Record<string, SealedAttachmentKey>;
|
|
101
|
+
}
|
|
102
|
+
export interface Recipient {
|
|
103
|
+
/** How the conversation names this person. `conversation_members.user_id`. */
|
|
104
|
+
memberId: string;
|
|
105
|
+
/** Their DM public key, as `dmPublicKey` returns it. */
|
|
106
|
+
publicKey: Uint8Array;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Encrypt a message and wrap its key for everybody who should read it.
|
|
110
|
+
*
|
|
111
|
+
* `recipients` is the whole membership, **including the sender**. Leaving the
|
|
112
|
+
* sender out compiles, sends, and produces a conversation the sender cannot
|
|
113
|
+
* read back — which is not something a type or a running app makes obvious,
|
|
114
|
+
* because the sender is looking at the plaintext they just typed. So it is
|
|
115
|
+
* checked here rather than left to every caller.
|
|
116
|
+
*
|
|
117
|
+
* The sender's own entry is wrapped with `dmSharedSecret(theirPrivate,
|
|
118
|
+
* theirPublic, …)`, which is a perfectly ordinary X25519 agreement that happens
|
|
119
|
+
* to have the same key on both sides. Nobody else can compute it.
|
|
120
|
+
*/
|
|
121
|
+
export declare function sealMessage({ plaintext, conversationId, senderKeys, recipients, attachments, }: {
|
|
122
|
+
plaintext: string;
|
|
123
|
+
conversationId: string;
|
|
124
|
+
senderKeys: DmKeyPair;
|
|
125
|
+
recipients: Recipient[];
|
|
126
|
+
/**
|
|
127
|
+
* File id to what `sealAttachment` handed back for it (GRYT-729).
|
|
128
|
+
*
|
|
129
|
+
* Whoever can read the message can open its files, and nobody else — which is
|
|
130
|
+
* the same statement the text carries, made once rather than twice.
|
|
131
|
+
*/
|
|
132
|
+
attachments?: Record<string, SealedAttachmentKey>;
|
|
133
|
+
}): Promise<SealedMessage>;
|
|
134
|
+
/**
|
|
135
|
+
* Read a message, if this member has a key for it.
|
|
136
|
+
*
|
|
137
|
+
* Returns null when there is no wrapped key for `memberId` — somebody who
|
|
138
|
+
* joined after this was sent, or a message that was never addressed to them.
|
|
139
|
+
* That is an ordinary outcome and not an error: a client rendering a
|
|
140
|
+
* conversation will hit it whenever somebody was added, and it should draw
|
|
141
|
+
* something honest rather than throw.
|
|
142
|
+
*
|
|
143
|
+
* A key that is present and does not open, on the other hand, throws. That
|
|
144
|
+
* means tampering, the wrong conversation, or the wrong keys, and swallowing it
|
|
145
|
+
* would show an empty message where something is actually wrong.
|
|
146
|
+
*/
|
|
147
|
+
export declare function openMessage({ sealed, conversationId, memberId, recipientKeys, }: {
|
|
148
|
+
sealed: SealedMessage;
|
|
149
|
+
conversationId: string;
|
|
150
|
+
/** Which member you are, as the conversation names you. */
|
|
151
|
+
memberId: string;
|
|
152
|
+
recipientKeys: DmKeyPair;
|
|
153
|
+
}): Promise<OpenedMessage | null>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One key per message, wrapped once per member (GRYT-718).
|
|
3
|
+
*
|
|
4
|
+
* `dm-keys.ts` gives two people a shared secret. Three people do not have one,
|
|
5
|
+
* and a group is what `conversations.ts` calls a conversation with `kind:
|
|
6
|
+
* "group"` — so the shape here is the one GRYT-709 picked: a random key for the
|
|
7
|
+
* message, encrypted once for each member with the secret the sender shares
|
|
8
|
+
* with that member.
|
|
9
|
+
*
|
|
10
|
+
* It scales with the member cap the server already enforces rather than with
|
|
11
|
+
* anything unbounded, and it needs no group ratchet, no key agreement between
|
|
12
|
+
* the members themselves, and nothing kept between messages.
|
|
13
|
+
*
|
|
14
|
+
* ## Membership changes stop being a policy question
|
|
15
|
+
*
|
|
16
|
+
* `conversations.ts` worries about a message "becoming readable by a third
|
|
17
|
+
* because somebody tapped add", and today it answers by refusing to turn a
|
|
18
|
+
* one-to-one into a group. Here the answer is structural: somebody added
|
|
19
|
+
* afterwards has no wrapped key in any message sent before they arrived, so
|
|
20
|
+
* those messages stay unreadable to them. Nothing enforces that; there is
|
|
21
|
+
* simply nothing for them to open.
|
|
22
|
+
*
|
|
23
|
+
* The reverse holds too. Removing somebody does not take back what they could
|
|
24
|
+
* already read, and no design can — they had the key.
|
|
25
|
+
*
|
|
26
|
+
* ## What this is not
|
|
27
|
+
*
|
|
28
|
+
* **Not authenticated as coming from the sender.** Everyone holding the content
|
|
29
|
+
* key can encrypt with it, so any member could produce a message the others
|
|
30
|
+
* decrypt happily. Saying *who wrote it* is a signature with the identity key,
|
|
31
|
+
* and that is not here yet.
|
|
32
|
+
*
|
|
33
|
+
* **Not end-to-end against the server, yet.** Wrapping to a public key is only
|
|
34
|
+
* worth something if the public key belongs to who you think. That is the
|
|
35
|
+
* certificate GRYT-709 describes, and until a peer can check one themselves a
|
|
36
|
+
* caller is trusting the server for it. `dmSharedSecret` says the same thing
|
|
37
|
+
* from the other end.
|
|
38
|
+
*
|
|
39
|
+
* **Not private about who is in the conversation.** The wrapped keys are listed
|
|
40
|
+
* by member id, so a sealed message names its own recipients. The server that
|
|
41
|
+
* stores it already stores the membership, so this gives away nothing it did
|
|
42
|
+
* not have — but it does mean the ciphertext is not anonymous on its own.
|
|
43
|
+
*/
|
|
44
|
+
/*
|
|
45
|
+
* The `.ts` is deliberate, and it is the only import in `src/` that carries one.
|
|
46
|
+
*
|
|
47
|
+
* `scripts/check-message-keys.mjs` runs this file through Node's type
|
|
48
|
+
* stripping, which does no extension inference — extensionless, Node looks for
|
|
49
|
+
* `dm-keys` on disk, does not find it, and the check cannot run at all. Vite and
|
|
50
|
+
* `tsc` both resolve the explicit extension, and `allowImportingTsExtensions` is
|
|
51
|
+
* already on in `tsconfig.app.json`.
|
|
52
|
+
*/
|
|
53
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
54
|
+
import { base64Url, base64UrlDecode } from "./base64.js";
|
|
55
|
+
import { dmSharedSecret } from "./dm-keys.js";
|
|
56
|
+
const IV_BYTES = 12;
|
|
57
|
+
const CONTENT_KEY_BYTES = 32;
|
|
58
|
+
export const SEALED_MESSAGE_TYPE = "gryt-sealed-message";
|
|
59
|
+
function randomBytes(length) {
|
|
60
|
+
const bytes = new Uint8Array(length);
|
|
61
|
+
crypto.getRandomValues(bytes);
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* AES-256-GCM, from a library rather than from the platform (GRYT-733).
|
|
66
|
+
*
|
|
67
|
+
* `crypto.subtle` is not on React Native, and this file has to run there
|
|
68
|
+
* unchanged — two implementations of one envelope is a pair of clients that
|
|
69
|
+
* send each other messages nobody can read, with the sender looking at the text
|
|
70
|
+
* they typed either way.
|
|
71
|
+
*
|
|
72
|
+
* The bytes are the same. A twelve-byte nonce, a sixteen-byte tag appended to
|
|
73
|
+
* the ciphertext, additional data authenticated and not encrypted: that is what
|
|
74
|
+
* WebCrypto produced and what this produces, so everything sealed before this
|
|
75
|
+
* change still opens.
|
|
76
|
+
*/
|
|
77
|
+
function aesGcm(key, iv, aad) {
|
|
78
|
+
return gcm(key, iv, aad);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* What the body is bound to, so it cannot be moved somewhere it does not belong.
|
|
82
|
+
*
|
|
83
|
+
* AES-GCM's additional data is authenticated but not encrypted, and decryption
|
|
84
|
+
* fails if it differs. Putting the conversation id in means a sealed message
|
|
85
|
+
* lifted out of one conversation and posted into another does not open, even
|
|
86
|
+
* though the same people and the same keys are involved — without it, the same
|
|
87
|
+
* pair talking in two conversations could have a message replayed between them.
|
|
88
|
+
*
|
|
89
|
+
* The sender goes in for the same reason: re-labelling a message as somebody
|
|
90
|
+
* else's breaks it. That is a much weaker thing than a signature — a member with
|
|
91
|
+
* the content key can seal a fresh message under any sender they like — but it
|
|
92
|
+
* costs nothing and closes the lazier version.
|
|
93
|
+
*/
|
|
94
|
+
function bodyContext(conversationId, sender) {
|
|
95
|
+
return new TextEncoder().encode(`${SEALED_MESSAGE_TYPE}/v1/${conversationId}/${sender}`);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The same, for one file's key.
|
|
99
|
+
*
|
|
100
|
+
* The file id is in here as well as inside the attachment's own envelope, so
|
|
101
|
+
* the entry for one file cannot be moved onto another and hand a reader the
|
|
102
|
+
* wrong key — which would fail to decrypt rather than open the wrong file, but
|
|
103
|
+
* fail in a way that reads like corruption instead of like tampering.
|
|
104
|
+
*/
|
|
105
|
+
function fileKeyContext(conversationId, sender, fileId) {
|
|
106
|
+
return new TextEncoder().encode(`${SEALED_MESSAGE_TYPE}/v1/${conversationId}/${sender}/file/${fileId}`);
|
|
107
|
+
}
|
|
108
|
+
/** The same, for one member's wrapped key. */
|
|
109
|
+
function wrapContext(conversationId, sender, memberId) {
|
|
110
|
+
return new TextEncoder().encode(`${SEALED_MESSAGE_TYPE}/v1/${conversationId}/${sender}/${memberId}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Encrypt a message and wrap its key for everybody who should read it.
|
|
114
|
+
*
|
|
115
|
+
* `recipients` is the whole membership, **including the sender**. Leaving the
|
|
116
|
+
* sender out compiles, sends, and produces a conversation the sender cannot
|
|
117
|
+
* read back — which is not something a type or a running app makes obvious,
|
|
118
|
+
* because the sender is looking at the plaintext they just typed. So it is
|
|
119
|
+
* checked here rather than left to every caller.
|
|
120
|
+
*
|
|
121
|
+
* The sender's own entry is wrapped with `dmSharedSecret(theirPrivate,
|
|
122
|
+
* theirPublic, …)`, which is a perfectly ordinary X25519 agreement that happens
|
|
123
|
+
* to have the same key on both sides. Nobody else can compute it.
|
|
124
|
+
*/
|
|
125
|
+
export async function sealMessage({ plaintext, conversationId, senderKeys, recipients, attachments, }) {
|
|
126
|
+
if (recipients.length === 0) {
|
|
127
|
+
throw new Error("A message with no recipients cannot be read by anybody.");
|
|
128
|
+
}
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const { memberId } of recipients) {
|
|
131
|
+
if (seen.has(memberId)) {
|
|
132
|
+
throw new Error(`Member ${memberId} is in the recipient list twice.`);
|
|
133
|
+
}
|
|
134
|
+
seen.add(memberId);
|
|
135
|
+
}
|
|
136
|
+
const sender = base64Url(senderKeys.publicKey);
|
|
137
|
+
if (!recipients.some((r) => base64Url(r.publicKey) === sender)) {
|
|
138
|
+
throw new Error("The sender is not among the recipients, so they could not read this back.");
|
|
139
|
+
}
|
|
140
|
+
const contentKey = randomBytes(CONTENT_KEY_BYTES);
|
|
141
|
+
const iv = randomBytes(IV_BYTES);
|
|
142
|
+
const body = aesGcm(contentKey, iv, bodyContext(conversationId, sender)).encrypt(new TextEncoder().encode(plaintext));
|
|
143
|
+
const keys = {};
|
|
144
|
+
for (const recipient of recipients) {
|
|
145
|
+
const secret = dmSharedSecret(senderKeys.privateKey, recipient.publicKey, conversationId);
|
|
146
|
+
const wrapIv = randomBytes(IV_BYTES);
|
|
147
|
+
const wrapped = aesGcm(secret, wrapIv, wrapContext(conversationId, sender, recipient.memberId)).encrypt(contentKey);
|
|
148
|
+
keys[recipient.memberId] = {
|
|
149
|
+
iv: base64Url(wrapIv),
|
|
150
|
+
key: base64Url(wrapped),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const files = {};
|
|
154
|
+
for (const [fileId, meta] of Object.entries(attachments ?? {})) {
|
|
155
|
+
const fileIv = randomBytes(IV_BYTES);
|
|
156
|
+
files[fileId] = {
|
|
157
|
+
iv: base64Url(fileIv),
|
|
158
|
+
meta: base64Url(aesGcm(contentKey, fileIv, fileKeyContext(conversationId, sender, fileId)).encrypt(new TextEncoder().encode(JSON.stringify(meta)))),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
type: SEALED_MESSAGE_TYPE,
|
|
163
|
+
version: 1,
|
|
164
|
+
sender,
|
|
165
|
+
iv: base64Url(iv),
|
|
166
|
+
body: base64Url(body),
|
|
167
|
+
keys,
|
|
168
|
+
// Left off entirely when there are none, so a message with no files is the
|
|
169
|
+
// same bytes it was before this existed and `check-crypto-vectors.mjs`
|
|
170
|
+
// keeps meaning what it means.
|
|
171
|
+
...(Object.keys(files).length > 0 ? { files } : null),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read a message, if this member has a key for it.
|
|
176
|
+
*
|
|
177
|
+
* Returns null when there is no wrapped key for `memberId` — somebody who
|
|
178
|
+
* joined after this was sent, or a message that was never addressed to them.
|
|
179
|
+
* That is an ordinary outcome and not an error: a client rendering a
|
|
180
|
+
* conversation will hit it whenever somebody was added, and it should draw
|
|
181
|
+
* something honest rather than throw.
|
|
182
|
+
*
|
|
183
|
+
* A key that is present and does not open, on the other hand, throws. That
|
|
184
|
+
* means tampering, the wrong conversation, or the wrong keys, and swallowing it
|
|
185
|
+
* would show an empty message where something is actually wrong.
|
|
186
|
+
*/
|
|
187
|
+
export async function openMessage({ sealed, conversationId, memberId, recipientKeys, }) {
|
|
188
|
+
if (sealed.type !== SEALED_MESSAGE_TYPE || sealed.version !== 1) {
|
|
189
|
+
throw new Error("That is not a sealed message this version can read.");
|
|
190
|
+
}
|
|
191
|
+
const mine = sealed.keys[memberId];
|
|
192
|
+
if (!mine)
|
|
193
|
+
return null;
|
|
194
|
+
const secret = dmSharedSecret(recipientKeys.privateKey, base64UrlDecode(sealed.sender), conversationId);
|
|
195
|
+
const contentKey = aesGcm(secret, base64UrlDecode(mine.iv), wrapContext(conversationId, sealed.sender, memberId)).decrypt(base64UrlDecode(mine.key));
|
|
196
|
+
const plain = aesGcm(contentKey, base64UrlDecode(sealed.iv), bodyContext(conversationId, sealed.sender)).decrypt(base64UrlDecode(sealed.body));
|
|
197
|
+
const attachments = {};
|
|
198
|
+
for (const [fileId, entry] of Object.entries(sealed.files ?? {})) {
|
|
199
|
+
const meta = aesGcm(contentKey, base64UrlDecode(entry.iv), fileKeyContext(conversationId, sealed.sender, fileId)).decrypt(base64UrlDecode(entry.meta));
|
|
200
|
+
attachments[fileId] = JSON.parse(new TextDecoder().decode(meta));
|
|
201
|
+
}
|
|
202
|
+
return { text: new TextDecoder().decode(plain), attachments };
|
|
203
|
+
}
|