@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
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-on-first-use pinning of the people you talk to (GRYT-726).
|
|
3
|
+
*
|
|
4
|
+
* `dm-key-binding.ts` can check that a DM key and an identity key were chosen
|
|
5
|
+
* by the same person. It cannot say who that person is, and nothing in band
|
|
6
|
+
* can — so what makes a binding worth anything is that the same one keeps
|
|
7
|
+
* arriving. This is the module that remembers.
|
|
8
|
+
*
|
|
9
|
+
* `server-pins.ts` does exactly this for servers and has since GRYT-51. Same
|
|
10
|
+
* three moves: record on first sight, notice a change, refuse it. The shapes are
|
|
11
|
+
* deliberately similar and the storage is deliberately separate, because a
|
|
12
|
+
* server key and a person's key answer different questions, and one being
|
|
13
|
+
* forgotten should not take the other with it.
|
|
14
|
+
*
|
|
15
|
+
* ## Refusing is the feature
|
|
16
|
+
*
|
|
17
|
+
* A client that quietly encrypts to a new key once the old one stops matching
|
|
18
|
+
* has thrown away the only protection this design has. There is no automatic
|
|
19
|
+
* re-pin here at all. A change is reported and stays reported until somebody
|
|
20
|
+
* decides, because the two reasons for one — a person restored a different seed,
|
|
21
|
+
* or a server substituted a key — look identical from here, and only one of them
|
|
22
|
+
* is the person's own doing.
|
|
23
|
+
*
|
|
24
|
+
* ## Both halves are compared, not just the identity
|
|
25
|
+
*
|
|
26
|
+
* An account holder's identity key is generated once and kept; their DM key is
|
|
27
|
+
* derived from the seed. Somebody who restores a different seed therefore keeps
|
|
28
|
+
* the same identity key and arrives with a different DM key, and comparing only
|
|
29
|
+
* the thumbprint would wave that through. Comparing only the DM key misses the
|
|
30
|
+
* reverse. Both, or the check has a hole in whichever direction is left out.
|
|
31
|
+
*
|
|
32
|
+
* This module decides. It does not fetch, encrypt, or draw anything.
|
|
33
|
+
*/
|
|
34
|
+
import { type VerifiedDmKeyBinding } from "./dm-key-binding.js";
|
|
35
|
+
import type { IdentityScope } from "./scope.js";
|
|
36
|
+
/**
|
|
37
|
+
* Where pins are kept, which this package deliberately does not decide.
|
|
38
|
+
*
|
|
39
|
+
* The desktop has `localStorage` and React Native does not. Rather than an
|
|
40
|
+
* async storage abstraction — which would make every read here async and ripple
|
|
41
|
+
* into a member list drawn synchronously — the caller hands over something it
|
|
42
|
+
* can read and write without waiting.
|
|
43
|
+
*
|
|
44
|
+
* On the desktop that is `localStorage`. On mobile it is a value held in memory,
|
|
45
|
+
* hydrated once at startup and flushed after a write. Both are ordinary and
|
|
46
|
+
* neither belongs in here.
|
|
47
|
+
*/
|
|
48
|
+
export interface PeerPinStore {
|
|
49
|
+
read(): Record<string, PeerPin>;
|
|
50
|
+
write(pins: Record<string, PeerPin>): void;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* What a caller should file these under, offered so the two clients do not pick
|
|
54
|
+
* different keys and quietly stop being the same app.
|
|
55
|
+
*/
|
|
56
|
+
export declare const PEER_PINS_KEY = "peerDmKeyPins";
|
|
57
|
+
export interface PeerPin {
|
|
58
|
+
/** The identity key that signed the binding, as a JWK thumbprint. */
|
|
59
|
+
thumbprint: string;
|
|
60
|
+
/** The DM public key it vouched for, base64url. */
|
|
61
|
+
dmPublicKey: string;
|
|
62
|
+
firstSeenAt: number;
|
|
63
|
+
lastSeenAt: number;
|
|
64
|
+
/**
|
|
65
|
+
* When these exact keys were compared out of band (GRYT-730).
|
|
66
|
+
*
|
|
67
|
+
* Absent until two people have read the code to each other. Not carried
|
|
68
|
+
* across a change — `pinPeerKey` drops it whenever either half moves, because
|
|
69
|
+
* a comparison is about the specific keys that were compared and keeping it
|
|
70
|
+
* would turn the one honest claim here into the lie it exists to prevent.
|
|
71
|
+
*/
|
|
72
|
+
comparedAt?: number;
|
|
73
|
+
}
|
|
74
|
+
export type PeerKeyDecision =
|
|
75
|
+
/** They have published nothing. Nothing to encrypt to, and nothing wrong. */
|
|
76
|
+
{
|
|
77
|
+
kind: "none";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Something arrived and did not check out — a signature that fails, a binding
|
|
81
|
+
* signed for another server, a shape that is not one at all.
|
|
82
|
+
*
|
|
83
|
+
* Not the same as a changed key. This is a server sending something broken
|
|
84
|
+
* rather than something plausible, and it never becomes a pin.
|
|
85
|
+
*/
|
|
86
|
+
| {
|
|
87
|
+
kind: "unusable";
|
|
88
|
+
reason: string;
|
|
89
|
+
}
|
|
90
|
+
/** Nobody pinned yet. The caller pins this and carries on. */
|
|
91
|
+
| {
|
|
92
|
+
kind: "first";
|
|
93
|
+
verified: VerifiedDmKeyBinding;
|
|
94
|
+
}
|
|
95
|
+
/** The same person and the same keys as last time. */
|
|
96
|
+
| {
|
|
97
|
+
kind: "known";
|
|
98
|
+
verified: VerifiedDmKeyBinding;
|
|
99
|
+
pin: PeerPin;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Different from what was pinned. Refuse, say so, and let somebody decide.
|
|
103
|
+
*
|
|
104
|
+
* `changedIdentity` and `changedKey` are separate because they mean different
|
|
105
|
+
* things to a person: a new identity key is somebody arriving as a different
|
|
106
|
+
* account, and a new DM key under the same identity is usually a restored
|
|
107
|
+
* seed.
|
|
108
|
+
*/
|
|
109
|
+
| {
|
|
110
|
+
kind: "changed";
|
|
111
|
+
pin: PeerPin;
|
|
112
|
+
verified: VerifiedDmKeyBinding;
|
|
113
|
+
changedIdentity: boolean;
|
|
114
|
+
changedKey: boolean;
|
|
115
|
+
};
|
|
116
|
+
export declare function listPeerPins(store: PeerPinStore): Record<string, PeerPin>;
|
|
117
|
+
export declare function getPeerPin(store: PeerPinStore, scope: IdentityScope, memberId: string): PeerPin | null;
|
|
118
|
+
/**
|
|
119
|
+
* Record what this member's keys are, from here on.
|
|
120
|
+
*
|
|
121
|
+
* Called on a `first` decision, and on a `changed` one only after somebody has
|
|
122
|
+
* said to. Nothing calls it on `changed` by itself, which is the whole point.
|
|
123
|
+
*/
|
|
124
|
+
export declare function pinPeerKey(store: PeerPinStore, scope: IdentityScope, memberId: string, verified: VerifiedDmKeyBinding, now?: number): PeerPin;
|
|
125
|
+
/**
|
|
126
|
+
* Record that these keys were read out and matched (GRYT-730).
|
|
127
|
+
*
|
|
128
|
+
* Takes the keys it is marking rather than just the member, and refuses if they
|
|
129
|
+
* are not the ones pinned. Between somebody reading a code aloud and pressing
|
|
130
|
+
* the button, a member list can land and change the pin — marking blind would
|
|
131
|
+
* put "verified" against keys nobody ever compared.
|
|
132
|
+
*/
|
|
133
|
+
export declare function markPeerCompared(store: PeerPinStore, scope: IdentityScope, memberId: string, keys: {
|
|
134
|
+
thumbprint: string;
|
|
135
|
+
dmPublicKey: string;
|
|
136
|
+
}, now?: number): boolean;
|
|
137
|
+
/** Forget one, which is what accepting a change amounts to before re-pinning. */
|
|
138
|
+
export declare function forgetPeerPin(store: PeerPinStore, scope: IdentityScope, memberId: string): void;
|
|
139
|
+
/** Forget everybody on one server, for a server being left. */
|
|
140
|
+
export declare function forgetPeerPinsForScope(store: PeerPinStore, scope: IdentityScope): void;
|
|
141
|
+
/**
|
|
142
|
+
* What to do about the binding this member list carried.
|
|
143
|
+
*
|
|
144
|
+
* Decides and returns. Nothing is written here, including on `first` — the same
|
|
145
|
+
* evaluation runs on every member list, and a function that pinned as a side
|
|
146
|
+
* effect would make `first` mean "since the last render".
|
|
147
|
+
*/
|
|
148
|
+
export declare function evaluatePeerKey({ store, scope, memberId, binding, }: {
|
|
149
|
+
/** Where pins live. See {@link PeerPinStore}. */
|
|
150
|
+
store: PeerPinStore;
|
|
151
|
+
scope: IdentityScope;
|
|
152
|
+
memberId: string;
|
|
153
|
+
/** Straight off the member list. Null when they have published nothing. */
|
|
154
|
+
binding: string | null | undefined;
|
|
155
|
+
}): Promise<PeerKeyDecision>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust-on-first-use pinning of the people you talk to (GRYT-726).
|
|
3
|
+
*
|
|
4
|
+
* `dm-key-binding.ts` can check that a DM key and an identity key were chosen
|
|
5
|
+
* by the same person. It cannot say who that person is, and nothing in band
|
|
6
|
+
* can — so what makes a binding worth anything is that the same one keeps
|
|
7
|
+
* arriving. This is the module that remembers.
|
|
8
|
+
*
|
|
9
|
+
* `server-pins.ts` does exactly this for servers and has since GRYT-51. Same
|
|
10
|
+
* three moves: record on first sight, notice a change, refuse it. The shapes are
|
|
11
|
+
* deliberately similar and the storage is deliberately separate, because a
|
|
12
|
+
* server key and a person's key answer different questions, and one being
|
|
13
|
+
* forgotten should not take the other with it.
|
|
14
|
+
*
|
|
15
|
+
* ## Refusing is the feature
|
|
16
|
+
*
|
|
17
|
+
* A client that quietly encrypts to a new key once the old one stops matching
|
|
18
|
+
* has thrown away the only protection this design has. There is no automatic
|
|
19
|
+
* re-pin here at all. A change is reported and stays reported until somebody
|
|
20
|
+
* decides, because the two reasons for one — a person restored a different seed,
|
|
21
|
+
* or a server substituted a key — look identical from here, and only one of them
|
|
22
|
+
* is the person's own doing.
|
|
23
|
+
*
|
|
24
|
+
* ## Both halves are compared, not just the identity
|
|
25
|
+
*
|
|
26
|
+
* An account holder's identity key is generated once and kept; their DM key is
|
|
27
|
+
* derived from the seed. Somebody who restores a different seed therefore keeps
|
|
28
|
+
* the same identity key and arrives with a different DM key, and comparing only
|
|
29
|
+
* the thumbprint would wave that through. Comparing only the DM key misses the
|
|
30
|
+
* reverse. Both, or the check has a hole in whichever direction is left out.
|
|
31
|
+
*
|
|
32
|
+
* This module decides. It does not fetch, encrypt, or draw anything.
|
|
33
|
+
*/
|
|
34
|
+
import { base64Url } from "./base64.js";
|
|
35
|
+
import { verifyDmKeyBinding, } from "./dm-key-binding.js";
|
|
36
|
+
/**
|
|
37
|
+
* What a caller should file these under, offered so the two clients do not pick
|
|
38
|
+
* different keys and quietly stop being the same app.
|
|
39
|
+
*/
|
|
40
|
+
export const PEER_PINS_KEY = "peerDmKeyPins";
|
|
41
|
+
/**
|
|
42
|
+
* One pin per server and member.
|
|
43
|
+
*
|
|
44
|
+
* A `server_user_id` is already per-server, so the scope is redundant for
|
|
45
|
+
* uniqueness. It is in the key anyway so that forgetting a server forgets the
|
|
46
|
+
* people on it, and so nothing rests on ids from two servers never colliding.
|
|
47
|
+
*/
|
|
48
|
+
function pinKey(scope, memberId) {
|
|
49
|
+
return `${scope} ${memberId}`;
|
|
50
|
+
}
|
|
51
|
+
export function listPeerPins(store) {
|
|
52
|
+
return store.read();
|
|
53
|
+
}
|
|
54
|
+
export function getPeerPin(store, scope, memberId) {
|
|
55
|
+
return store.read()[pinKey(scope, memberId)] ?? null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Record what this member's keys are, from here on.
|
|
59
|
+
*
|
|
60
|
+
* Called on a `first` decision, and on a `changed` one only after somebody has
|
|
61
|
+
* said to. Nothing calls it on `changed` by itself, which is the whole point.
|
|
62
|
+
*/
|
|
63
|
+
export function pinPeerKey(store, scope, memberId, verified, now = Date.now()) {
|
|
64
|
+
const pins = store.read();
|
|
65
|
+
const key = pinKey(scope, memberId);
|
|
66
|
+
const existing = pins[key];
|
|
67
|
+
const sameKeys = existing?.thumbprint === verified.identityThumbprint &&
|
|
68
|
+
existing?.dmPublicKey === base64Url(verified.dmPublicKey);
|
|
69
|
+
const pin = {
|
|
70
|
+
thumbprint: verified.identityThumbprint,
|
|
71
|
+
dmPublicKey: base64Url(verified.dmPublicKey),
|
|
72
|
+
// Kept across a deliberate re-pin, so "known since" stays true to when this
|
|
73
|
+
// person was first seen rather than to when they last changed devices.
|
|
74
|
+
firstSeenAt: existing?.firstSeenAt ?? now,
|
|
75
|
+
lastSeenAt: now,
|
|
76
|
+
// Dropped the moment either key moves. Somebody who compared a code last
|
|
77
|
+
// year and whose peer has since arrived with a new key has verified
|
|
78
|
+
// nothing, and a card still saying "verified" would be worse than one that
|
|
79
|
+
// never said it.
|
|
80
|
+
comparedAt: sameKeys ? existing?.comparedAt : undefined,
|
|
81
|
+
};
|
|
82
|
+
pins[key] = pin;
|
|
83
|
+
store.write(pins);
|
|
84
|
+
return pin;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record that these keys were read out and matched (GRYT-730).
|
|
88
|
+
*
|
|
89
|
+
* Takes the keys it is marking rather than just the member, and refuses if they
|
|
90
|
+
* are not the ones pinned. Between somebody reading a code aloud and pressing
|
|
91
|
+
* the button, a member list can land and change the pin — marking blind would
|
|
92
|
+
* put "verified" against keys nobody ever compared.
|
|
93
|
+
*/
|
|
94
|
+
export function markPeerCompared(store, scope, memberId, keys, now = Date.now()) {
|
|
95
|
+
const pins = store.read();
|
|
96
|
+
const key = pinKey(scope, memberId);
|
|
97
|
+
const pin = pins[key];
|
|
98
|
+
if (!pin ||
|
|
99
|
+
pin.thumbprint !== keys.thumbprint ||
|
|
100
|
+
pin.dmPublicKey !== keys.dmPublicKey) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
pins[key] = { ...pin, comparedAt: now };
|
|
104
|
+
store.write(pins);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** Forget one, which is what accepting a change amounts to before re-pinning. */
|
|
108
|
+
export function forgetPeerPin(store, scope, memberId) {
|
|
109
|
+
const pins = store.read();
|
|
110
|
+
delete pins[pinKey(scope, memberId)];
|
|
111
|
+
store.write(pins);
|
|
112
|
+
}
|
|
113
|
+
/** Forget everybody on one server, for a server being left. */
|
|
114
|
+
export function forgetPeerPinsForScope(store, scope) {
|
|
115
|
+
const pins = store.read();
|
|
116
|
+
const prefix = `${scope} `;
|
|
117
|
+
for (const key of Object.keys(pins)) {
|
|
118
|
+
if (key.startsWith(prefix))
|
|
119
|
+
delete pins[key];
|
|
120
|
+
}
|
|
121
|
+
store.write(pins);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What to do about the binding this member list carried.
|
|
125
|
+
*
|
|
126
|
+
* Decides and returns. Nothing is written here, including on `first` — the same
|
|
127
|
+
* evaluation runs on every member list, and a function that pinned as a side
|
|
128
|
+
* effect would make `first` mean "since the last render".
|
|
129
|
+
*/
|
|
130
|
+
export async function evaluatePeerKey({ store, scope, memberId, binding, }) {
|
|
131
|
+
if (!binding)
|
|
132
|
+
return { kind: "none" };
|
|
133
|
+
let verified;
|
|
134
|
+
try {
|
|
135
|
+
verified = await verifyDmKeyBinding(binding, scope);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return {
|
|
139
|
+
kind: "unusable",
|
|
140
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const pin = getPeerPin(store, scope, memberId);
|
|
144
|
+
if (!pin)
|
|
145
|
+
return { kind: "first", verified };
|
|
146
|
+
const changedIdentity = pin.thumbprint !== verified.identityThumbprint;
|
|
147
|
+
const changedKey = pin.dmPublicKey !== base64Url(verified.dmPublicKey);
|
|
148
|
+
if (changedIdentity || changedKey) {
|
|
149
|
+
return { kind: "changed", pin, verified, changedIdentity, changedKey };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "known", verified, pin };
|
|
152
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a per-server key is derived under.
|
|
3
|
+
*
|
|
4
|
+
* A branded string rather than a comment, because `derive(seed, host)` and
|
|
5
|
+
* `derive(seed, scope)` are the same call to a type checker and the difference
|
|
6
|
+
* only shows up when somebody's server moves — at which point every message
|
|
7
|
+
* encrypted to the old key is unreadable and nothing says why (GRYT-719).
|
|
8
|
+
*
|
|
9
|
+
* The two clients disagree about what a scope *is*, and that is deliberate. On
|
|
10
|
+
* the desktop it is the server's lineage id, from the pin, so a server that
|
|
11
|
+
* changes address stays the same server. Mobile still files its identity under
|
|
12
|
+
* the address (GRYT-517) and passes the lineage in here anyway, because a DM key
|
|
13
|
+
* has no history to migrate and should not inherit that one.
|
|
14
|
+
*
|
|
15
|
+
* Which is exactly why this package takes the scope rather than working it out:
|
|
16
|
+
* where it comes from is the caller's business, and it is not the same business
|
|
17
|
+
* on both.
|
|
18
|
+
*/
|
|
19
|
+
export type IdentityScope = string & {
|
|
20
|
+
readonly __identityScope: unique symbol;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Say that a string is a scope.
|
|
24
|
+
*
|
|
25
|
+
* The brand has to be mintable somewhere or nothing could call these functions.
|
|
26
|
+
* Keeping it to one named function means every scope is somewhere a person
|
|
27
|
+
* wrote down that they meant one.
|
|
28
|
+
*
|
|
29
|
+
* It checks nothing and cannot: a scope is legitimately a bare address for a
|
|
30
|
+
* server that offered no proof, so "looks like a host" is not a signal. What it
|
|
31
|
+
* buys is that `deriveDmKeyPair(seed, host)` does not compile.
|
|
32
|
+
*/
|
|
33
|
+
export declare function asIdentityScope(value: string): IdentityScope;
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Say that a string is a scope.
|
|
3
|
+
*
|
|
4
|
+
* The brand has to be mintable somewhere or nothing could call these functions.
|
|
5
|
+
* Keeping it to one named function means every scope is somewhere a person
|
|
6
|
+
* wrote down that they meant one.
|
|
7
|
+
*
|
|
8
|
+
* It checks nothing and cannot: a scope is legitimately a bare address for a
|
|
9
|
+
* server that offered no proof, so "looks like a host" is not a signal. What it
|
|
10
|
+
* buys is that `deriveDmKeyPair(seed, host)` does not compile.
|
|
11
|
+
*/
|
|
12
|
+
export function asIdentityScope(value) {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JWK thumbprint, RFC 7638, for the EC public keys Gryt uses.
|
|
3
|
+
*
|
|
4
|
+
* Copied out of the client's `server-pins.ts` rather than imported, because
|
|
5
|
+
* that module is about pinning *servers* and this package has no business
|
|
6
|
+
* knowing about those. The bytes are identical: the same canonical JSON with
|
|
7
|
+
* the members in lexicographic order, SHA-256, base64url.
|
|
8
|
+
*
|
|
9
|
+
* `@noble/hashes` rather than `crypto.subtle.digest`, so it runs on React
|
|
10
|
+
* Native (GRYT-733) — and synchronous, which the WebCrypto version could not be.
|
|
11
|
+
*/
|
|
12
|
+
export declare function jwkThumbprint(jwk: {
|
|
13
|
+
kty?: string;
|
|
14
|
+
crv?: string;
|
|
15
|
+
x?: string;
|
|
16
|
+
y?: string;
|
|
17
|
+
}): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
|
+
import { base64Url } from "./base64.js";
|
|
3
|
+
/**
|
|
4
|
+
* A JWK thumbprint, RFC 7638, for the EC public keys Gryt uses.
|
|
5
|
+
*
|
|
6
|
+
* Copied out of the client's `server-pins.ts` rather than imported, because
|
|
7
|
+
* that module is about pinning *servers* and this package has no business
|
|
8
|
+
* knowing about those. The bytes are identical: the same canonical JSON with
|
|
9
|
+
* the members in lexicographic order, SHA-256, base64url.
|
|
10
|
+
*
|
|
11
|
+
* `@noble/hashes` rather than `crypto.subtle.digest`, so it runs on React
|
|
12
|
+
* Native (GRYT-733) — and synchronous, which the WebCrypto version could not be.
|
|
13
|
+
*/
|
|
14
|
+
export function jwkThumbprint(jwk) {
|
|
15
|
+
if (jwk.kty !== "EC" || !jwk.crv || !jwk.x || !jwk.y) {
|
|
16
|
+
throw new Error("Not an EC public JWK");
|
|
17
|
+
}
|
|
18
|
+
// Lexicographic, and only these four. RFC 7638 says a thumbprint is over the
|
|
19
|
+
// required members with no whitespace — adding `ext` or `key_ops` would give
|
|
20
|
+
// the same key two different thumbprints depending on where it came from.
|
|
21
|
+
const canonical = JSON.stringify({
|
|
22
|
+
crv: jwk.crv,
|
|
23
|
+
kty: jwk.kty,
|
|
24
|
+
x: jwk.x,
|
|
25
|
+
y: jwk.y,
|
|
26
|
+
});
|
|
27
|
+
return base64Url(sha256(new TextEncoder().encode(canonical)));
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gryt/crypto",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Message encryption for Gryt: key derivation, key bindings, sealed envelopes, pinning and comparison codes. Shared by the desktop client and the mobile app.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/Gryt-chat/crypto.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json",
|
|
19
|
+
"./*": {
|
|
20
|
+
"types": "./dist/*.d.ts",
|
|
21
|
+
"default": "./dist/*.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node scripts/add-esm-extensions.mjs",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.check.json",
|
|
30
|
+
"test": "for f in scripts/check-*.mjs; do node \"$f\" || exit 1; done",
|
|
31
|
+
"prepublishOnly": "npm run build && npm test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@noble/ciphers": "^2",
|
|
35
|
+
"@noble/curves": "^2.3.0",
|
|
36
|
+
"@noble/hashes": "^2.3.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^20.19.33",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|