Skip to content

Commit 30b3a7c

Browse files
giodl73-repoPatrick-ErichsenGio Della-Libera
authored
feat: add hosted feed envelope verifier (#98299)
* feat: add hosted feed envelope verifier * refactor: share Ed25519 signature helpers * fix: repair hosted feed verifier checks * Use DSSE PAE for hosted feed envelopes * Bound raw hosted feed signatures * fix(feeds): verify decoded envelope payload bytes * chore: refresh npm shrinkwrap baselines * Revert "chore: refresh npm shrinkwrap baselines" This reverts commit 448f05f. --------- Co-authored-by: Patrick Erichsen <[email protected]> Co-authored-by: Gio Della-Libera <[email protected]>
1 parent 9c86529 commit 30b3a7c

5 files changed

Lines changed: 640 additions & 78 deletions

src/infra/device-identity.ts

Lines changed: 18 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ import crypto from "node:crypto";
33
import fs from "node:fs";
44
import path from "node:path";
55
import { resolveStateDir } from "../config/paths.js";
6+
import {
7+
base64UrlDecode,
8+
deriveEd25519PublicKeyRaw,
9+
ed25519PrivateKeyPemFromRaw,
10+
ed25519PublicKeyPemFromRaw,
11+
normalizeEd25519PublicKeyBase64Url,
12+
publicKeyRawBase64UrlFromEd25519Pem,
13+
signEd25519Payload,
14+
verifyEd25519Signature,
15+
} from "./ed25519-signature.js";
616
import { privateFileStoreSync } from "./private-file-store.js";
717

818
/** Gateway/device Ed25519 identity used for APNs relay and gateway authentication. */
@@ -31,51 +41,12 @@ function resolveDefaultIdentityPath(): string {
3141
return path.join(resolveStateDir(), "identity", "device.json");
3242
}
3343

34-
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
35-
const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
36-
37-
function base64UrlEncode(buf: Buffer): string {
38-
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
39-
}
40-
41-
function base64UrlDecode(input: string): Buffer {
42-
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
43-
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
44-
return Buffer.from(padded, "base64");
45-
}
46-
47-
function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string {
48-
const body =
49-
der
50-
.toString("base64")
51-
.match(/.{1,64}/g)
52-
?.join("\n") ?? "";
53-
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`;
54-
}
55-
5644
// Swift stores raw Ed25519 key bytes; Node crypto needs DER/PEM wrappers around them.
57-
function publicKeyPemFromRaw(publicKeyRaw: Buffer): string {
58-
return pemEncode("PUBLIC KEY", Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw]));
59-
}
60-
61-
function privateKeyPemFromRaw(privateKeyRaw: Buffer): string {
62-
return pemEncode("PRIVATE KEY", Buffer.concat([ED25519_PKCS8_PRIVATE_PREFIX, privateKeyRaw]));
63-
}
64-
65-
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
66-
const key = crypto.createPublicKey(publicKeyPem);
67-
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
68-
if (
69-
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
70-
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
71-
) {
72-
return spki.subarray(ED25519_SPKI_PREFIX.length);
73-
}
74-
return spki;
75-
}
45+
const publicKeyPemFromRaw = ed25519PublicKeyPemFromRaw;
46+
const privateKeyPemFromRaw = ed25519PrivateKeyPemFromRaw;
7647

7748
function fingerprintPublicKey(publicKeyPem: string): string {
78-
const raw = derivePublicKeyRaw(publicKeyPem);
49+
const raw = deriveEd25519PublicKeyRaw(publicKeyPem);
7950
return crypto.createHash("sha256").update(raw).digest("hex");
8051
}
8152

@@ -305,32 +276,19 @@ export function loadDeviceIdentityIfPresent(
305276

306277
/** Sign a UTF-8 payload with a PEM Ed25519 private key and return base64url bytes. */
307278
export function signDevicePayload(privateKeyPem: string, payload: string): string {
308-
const key = crypto.createPrivateKey(privateKeyPem);
309-
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
310-
return base64UrlEncode(sig);
279+
return signEd25519Payload(privateKeyPem, payload);
311280
}
312281

313282
/** Normalize PEM or raw base64/base64url public keys to canonical raw base64url bytes. */
314283
export function normalizeDevicePublicKeyBase64Url(publicKey: string): string | null {
315-
try {
316-
if (publicKey.includes("BEGIN")) {
317-
return base64UrlEncode(derivePublicKeyRaw(publicKey));
318-
}
319-
const raw = base64UrlDecode(publicKey);
320-
if (raw.length === 0) {
321-
return null;
322-
}
323-
return base64UrlEncode(raw);
324-
} catch {
325-
return null;
326-
}
284+
return normalizeEd25519PublicKeyBase64Url(publicKey);
327285
}
328286

329287
/** Derive the stable device id from PEM or raw base64/base64url public key material. */
330288
export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
331289
try {
332290
const raw = publicKey.includes("BEGIN")
333-
? derivePublicKeyRaw(publicKey)
291+
? deriveEd25519PublicKeyRaw(publicKey)
334292
: base64UrlDecode(publicKey);
335293
if (raw.length === 0) {
336294
return null;
@@ -343,7 +301,7 @@ export function deriveDeviceIdFromPublicKey(publicKey: string): string | null {
343301

344302
/** Export a PEM Ed25519 public key as canonical raw base64url bytes. */
345303
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
346-
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
304+
return publicKeyRawBase64UrlFromEd25519Pem(publicKeyPem);
347305
}
348306

349307
/** Verify a UTF-8 payload signature against PEM or raw base64/base64url public key material. */
@@ -352,23 +310,5 @@ export function verifyDeviceSignature(
352310
payload: string,
353311
signatureBase64Url: string,
354312
): boolean {
355-
try {
356-
const key = publicKey.includes("BEGIN")
357-
? crypto.createPublicKey(publicKey)
358-
: crypto.createPublicKey({
359-
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
360-
type: "spki",
361-
format: "der",
362-
});
363-
const sig = (() => {
364-
try {
365-
return base64UrlDecode(signatureBase64Url);
366-
} catch {
367-
return Buffer.from(signatureBase64Url, "base64");
368-
}
369-
})();
370-
return crypto.verify(null, Buffer.from(payload, "utf8"), key, sig);
371-
} catch {
372-
return false;
373-
}
313+
return verifyEd25519Signature({ publicKey, payload, signatureBase64Url });
374314
}

src/infra/ed25519-signature.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import crypto from "node:crypto";
2+
3+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
4+
const ED25519_PKCS8_PRIVATE_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
5+
6+
export function base64UrlEncode(buf: Buffer): string {
7+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
8+
}
9+
10+
export function base64UrlDecode(input: string): Buffer {
11+
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
12+
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
13+
return Buffer.from(padded, "base64");
14+
}
15+
16+
function pemEncode(label: "PUBLIC KEY" | "PRIVATE KEY", der: Buffer): string {
17+
const body =
18+
der
19+
.toString("base64")
20+
.match(/.{1,64}/g)
21+
?.join("\n") ?? "";
22+
return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----\n`;
23+
}
24+
25+
export function ed25519PublicKeyPemFromRaw(publicKeyRaw: Buffer): string {
26+
return pemEncode("PUBLIC KEY", Buffer.concat([ED25519_SPKI_PREFIX, publicKeyRaw]));
27+
}
28+
29+
export function ed25519PrivateKeyPemFromRaw(privateKeyRaw: Buffer): string {
30+
return pemEncode("PRIVATE KEY", Buffer.concat([ED25519_PKCS8_PRIVATE_PREFIX, privateKeyRaw]));
31+
}
32+
33+
export function deriveEd25519PublicKeyRaw(publicKeyPem: string): Buffer {
34+
const key = crypto.createPublicKey(publicKeyPem);
35+
const spki = key.export({ type: "spki", format: "der" }) as Buffer;
36+
if (
37+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
38+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
39+
) {
40+
return spki.subarray(ED25519_SPKI_PREFIX.length);
41+
}
42+
return spki;
43+
}
44+
45+
export function publicKeyRawBase64UrlFromEd25519Pem(publicKeyPem: string): string {
46+
return base64UrlEncode(deriveEd25519PublicKeyRaw(publicKeyPem));
47+
}
48+
49+
export function normalizeEd25519PublicKeyBase64Url(publicKey: string): string | null {
50+
try {
51+
if (publicKey.includes("BEGIN")) {
52+
return publicKeyRawBase64UrlFromEd25519Pem(publicKey);
53+
}
54+
const raw = base64UrlDecode(publicKey);
55+
if (raw.length === 0) {
56+
return null;
57+
}
58+
return base64UrlEncode(raw);
59+
} catch {
60+
return null;
61+
}
62+
}
63+
64+
export function signEd25519Payload(privateKeyPem: string, payload: string): string {
65+
const key = crypto.createPrivateKey(privateKeyPem);
66+
const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
67+
return base64UrlEncode(sig);
68+
}
69+
70+
function createEd25519PublicKey(publicKey: string): crypto.KeyObject {
71+
return publicKey.includes("BEGIN")
72+
? crypto.createPublicKey(publicKey)
73+
: crypto.createPublicKey({
74+
key: Buffer.concat([ED25519_SPKI_PREFIX, base64UrlDecode(publicKey)]),
75+
type: "spki",
76+
format: "der",
77+
});
78+
}
79+
80+
export function verifyEd25519Signature(params: {
81+
publicKey: string;
82+
payload: string;
83+
signatureBase64Url: string;
84+
}): boolean {
85+
return verifyEd25519SignatureBytes({
86+
publicKey: params.publicKey,
87+
payload: Buffer.from(params.payload, "utf8"),
88+
signatureBase64Url: params.signatureBase64Url,
89+
});
90+
}
91+
92+
export function verifyEd25519SignatureBytes(params: {
93+
publicKey: string;
94+
payload: Buffer;
95+
signatureBase64Url: string;
96+
}): boolean {
97+
try {
98+
const key = createEd25519PublicKey(params.publicKey);
99+
const sig = base64UrlDecode(params.signatureBase64Url);
100+
return crypto.verify(null, params.payload, key, sig);
101+
} catch {
102+
return false;
103+
}
104+
}

0 commit comments

Comments
 (0)