Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@aws-sdk/client-s3": "^3.994.0",
"@aws-sdk/lib-storage": "^3.1106.0",
"@aws-sdk/s3-request-presigner": "^3.994.0",
"@gryt/crypto": "0.7.0",
"bonjour-service": "^1.3.0",
"consola": "^3.4.2",
"dompurify": "^3.4.13",
Expand Down
2 changes: 2 additions & 0 deletions src/db/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface UserRecord {
/** Opaque on purpose: a server vouching for the binding would vouch for what a
member has to check anyway. */
dm_key_binding: string | null;
/** Signed by the same identity key as `dm_key_binding`, which is checked on the way in. */
person_key_binding: string | null;
/** Null when they have no designed look. Stored and passed on without being
read — see `utils/wornString.ts`. */
avatar_worn: string | null;
Expand Down
6 changes: 6 additions & 0 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,12 @@ function runMigrations(d: DatabaseSync): void {
d.exec("ALTER TABLE users ADD COLUMN dm_key_binding TEXT");
}

// The MLS person key binding (GRYT-1515). Checked against the DM key binding's
// signer when it's published, then stored whole like that one.
if (!hasColumn(d, "users", "person_key_binding")) {
d.exec("ALTER TABLE users ADD COLUMN person_key_binding TEXT");
}

// The whole envelope, untouched. With it set `text` is null and there is
// nothing to filter or moderate, so a channel cannot hold one.
if (!hasColumn(d, "messages", "sealed")) {
Expand Down
14 changes: 14 additions & 0 deletions src/db/sqlite/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function rowToUser(r: Record<string, unknown>): UserRecord {
: null,
avatar_worn: (r.avatar_worn as string) || null,
dm_key_binding: (r.dm_key_binding as string) || null,
person_key_binding: (r.person_key_binding as string) || null,
};
}

Expand Down Expand Up @@ -132,6 +133,7 @@ export async function upsertUser(
// Sent after joining, if at all. A client older than GRYT-720 never sends
// one, and a member with no binding simply has no encrypted messages.
dm_key_binding: null,
person_key_binding: null,
};
}

Expand All @@ -148,6 +150,18 @@ export async function setUserDmKeyBinding(
);
}

/** Checked by the caller (`services/personKeyBinding.ts`), not here. Null withdraws it. */
export async function setUserPersonKeyBinding(
serverUserId: string,
binding: string | null,
): Promise<void> {
const db = getSqliteDb();
db.prepare(`UPDATE users SET person_key_binding = ? WHERE server_user_id = ?`).run(
binding,
serverUserId,
);
}

export async function getUserByGrytId(grytUserId: string): Promise<UserRecord | null> {
const db = getSqliteDb();
const row = db.prepare(`SELECT * FROM users WHERE gryt_user_id = ?`).get(grytUserId) as Record<string, unknown> | undefined;
Expand Down
82 changes: 82 additions & 0 deletions src/services/personKeyBinding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { IdentityScope } from "@gryt/crypto/dist/scope";

/**
* The MLS person key binding a member publishes (GRYT-1515, mls-design.md section 1). Peers
* check it themselves; this keeps an honest server from handing out one they'd refuse.
*/

/* Subpaths, like mlsWire does with ts-mls: the package index pulls in all of MLS. */
/* eslint-disable @typescript-eslint/no-require-imports */
const { verifyPersonKeyBinding } = require("@gryt/crypto/mls-person-key") as typeof import("@gryt/crypto/dist/mls-person-key");
const { verifyDmKeyBinding } = require("@gryt/crypto/dm-key-binding") as typeof import("@gryt/crypto/dist/dm-key-binding");
/* eslint-enable @typescript-eslint/no-require-imports */

/** A real binding is about 500 bytes. The same ceiling as the DM key binding's. */
export const MAX_PERSON_KEY_BINDING_BYTES = 4096;

const COMPACT_JWT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;

export type PersonKeyCheck =
| { ok: true }
| {
ok: false;
error: "invalid_binding" | "no_dm_key" | "wrong_identity";
message: string;
};

/* The scope a binding names, so it can be verified under that one. Null when unreadable. */
function claimedScope(jwt: string): IdentityScope | null {
try {
const payload = JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8"));
return typeof payload?.scope === "string" ? (payload.scope as IdentityScope) : null;
} catch {
return null;
}
}

const refuse = (error: Exclude<PersonKeyCheck, { ok: true }>["error"], message: string): PersonKeyCheck => ({
ok: false,
error,
message,
});

/**
* Valid, and signed by the identity key that signed this member's DM key binding, for the
* same scope. That's the key peers have pinned, and `pinPersonKey` refuses any other signer.
*/
export async function checkPersonKeyBinding(
binding: string,
dmKeyBinding: string | null,
): Promise<PersonKeyCheck> {
if (binding.length > MAX_PERSON_KEY_BINDING_BYTES || !COMPACT_JWT.test(binding)) {
return refuse("invalid_binding", "That isn't a person key binding.");
}
const scope = claimedScope(binding);
if (!scope) return refuse("invalid_binding", "That person key binding names no scope.");

let person;
try {
person = await verifyPersonKeyBinding(binding, scope);
} catch (err) {
return refuse("invalid_binding", err instanceof Error ? err.message : "That person key binding does not verify.");
}

const dmScope = dmKeyBinding ? claimedScope(dmKeyBinding) : null;
if (!dmKeyBinding || !dmScope) {
return refuse("no_dm_key", "Publish your DM key first. The person key is checked against the identity that signed it.");
}
let dm;
try {
dm = await verifyDmKeyBinding(dmKeyBinding, dmScope);
} catch {
return refuse("no_dm_key", "Your DM key binding here doesn't verify, so there's nothing to check the person key against.");
}

if (person.identityThumbprint !== dm.identityThumbprint) {
return refuse("wrong_identity", "The person key binding is signed by a different identity key than your DM key binding.");
}
if (person.scope !== dm.scope) {
return refuse("wrong_identity", "The person key binding was signed for a different server than your DM key binding.");
}
return { ok: true };
}
Loading
Loading