Skip to content

Commit 949d4f8

Browse files
committed
Add marketplace content key protection helper
1 parent bee60ea commit 949d4f8

4 files changed

Lines changed: 403 additions & 0 deletions

File tree

src/index.d.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,90 @@ export interface EncryptedEnvelope {
641641

642642
export function generateX25519Keypair(): Promise<X25519Keypair>;
643643

644+
export interface MarketplaceArtifactBinding {
645+
listingId: string;
646+
moduleId: string;
647+
version: string;
648+
encryptedCid: string;
649+
encryptedHash: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | string;
650+
providerId: string;
651+
policyId: string;
652+
keyEpoch: string;
653+
manifestHash: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | string;
654+
contentKeyId?: string;
655+
}
656+
657+
export interface MarketplaceContentRecipient {
658+
recipientPeerId: string;
659+
recipientKeyId: string;
660+
publicKey: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | string;
661+
grantId: string;
662+
scope: string;
663+
expiresAtMs?: number;
664+
}
665+
666+
export interface MarketplaceContentKeyWrap {
667+
algorithm: string;
668+
contentKeyId: string;
669+
recipientPeerId: string;
670+
recipientKeyId: string;
671+
providerId: string;
672+
encryptedCid: string;
673+
grantId: string;
674+
scope: string;
675+
expiresAtMs: number;
676+
providerEphemeralPublicKey: Uint8Array;
677+
nonce: Uint8Array;
678+
aad: Uint8Array;
679+
ciphertext: Uint8Array;
680+
tag: Uint8Array;
681+
}
682+
683+
export interface MarketplaceProtectedContent {
684+
algorithm: "AES-256-GCM";
685+
contentKeyId: string;
686+
artifact: {
687+
listingId: string;
688+
moduleId: string;
689+
version: string;
690+
encryptedCid: string;
691+
encryptedHash: string;
692+
providerId: string;
693+
policyId: string;
694+
keyEpoch: string;
695+
manifestHash: string;
696+
contentKeyId: string;
697+
};
698+
aad: Uint8Array;
699+
encryptedPayload: {
700+
algorithm: "AES-256-GCM";
701+
nonce: Uint8Array;
702+
aad: Uint8Array;
703+
ciphertext: Uint8Array;
704+
tag: Uint8Array;
705+
};
706+
wrappedKeys: MarketplaceContentKeyWrap[];
707+
}
708+
709+
export function createMarketplaceContentAad(
710+
artifact: MarketplaceArtifactBinding,
711+
): Uint8Array;
712+
713+
export function protectMarketplaceContent(options: {
714+
plaintext: Uint8Array | ArrayBuffer | ArrayBufferView | number[];
715+
artifact: MarketplaceArtifactBinding;
716+
recipients: MarketplaceContentRecipient[];
717+
contentKey?: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | null;
718+
contentNonce?: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | null;
719+
providerWrapKeyPair?: X25519Keypair | null;
720+
wrapNonce?: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | null;
721+
}): Promise<MarketplaceProtectedContent>;
722+
723+
export function decryptMarketplaceContentKeyWrap(options: {
724+
wrap: MarketplaceContentKeyWrap;
725+
recipientPrivateKey: Uint8Array | ArrayBuffer | ArrayBufferView | number[] | string;
726+
}): Promise<Uint8Array>;
727+
644728
export function encryptBytesForRecipient(options: {
645729
plaintext: Uint8Array | ArrayBuffer;
646730
recipientPublicKey: Uint8Array | string;

src/transport/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
export {
2+
createMarketplaceContentAad,
23
decryptProtectedBytes,
34
decryptPublicationRecordCollection,
45
decryptBytesFromEnvelope,
6+
decryptMarketplaceContentKeyWrap,
57
decryptJsonFromEnvelope,
68
encryptBytesForRecipient,
79
encryptJsonForRecipient,
810
generateX25519Keypair,
11+
protectMarketplaceContent,
912
} from "./pki.js";
1013

1114
export {

src/transport/pki.js

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { canonicalBytes } from "../auth/canonicalize.js";
2+
import * as flatbuffers from "flatbuffers";
3+
import { KMF, keyMaterialAlgorithm, keyMaterialEncoding, keyMaterialRole } from "spacedatastandards.org/lib/js/KMF/main.js";
24
import {
35
base64ToBytes,
46
bytesToBase64,
7+
bytesToHex,
58
hexToBytes,
69
toUint8Array,
710
} from "../utils/encoding.js";
@@ -53,6 +56,130 @@ async function deriveAesKey(sharedSecret, salt, context) {
5356
);
5457
}
5558

59+
function normalizeOptionalBytes(value) {
60+
if (value === null || value === undefined) {
61+
return new Uint8Array(0);
62+
}
63+
if (typeof value === "string") {
64+
return hexToBytes(value);
65+
}
66+
return toUint8Array(value);
67+
}
68+
69+
function normalizeRequiredString(value, name) {
70+
const normalized = String(value ?? "").trim();
71+
if (!normalized) {
72+
throw new Error(`${name} is required.`);
73+
}
74+
return normalized;
75+
}
76+
77+
function normalizeArtifactBinding(artifact = {}) {
78+
const listingId = normalizeRequiredString(artifact.listingId, "artifact.listingId");
79+
const moduleId = normalizeRequiredString(artifact.moduleId, "artifact.moduleId");
80+
const version = normalizeRequiredString(artifact.version, "artifact.version");
81+
const encryptedCid = normalizeRequiredString(
82+
artifact.encryptedCid,
83+
"artifact.encryptedCid",
84+
);
85+
const providerId = normalizeRequiredString(artifact.providerId, "artifact.providerId");
86+
const policyId = normalizeRequiredString(artifact.policyId, "artifact.policyId");
87+
const keyEpoch = normalizeRequiredString(artifact.keyEpoch, "artifact.keyEpoch");
88+
const contentKeyId =
89+
String(artifact.contentKeyId ?? "").trim() ||
90+
`${listingId}:${moduleId}:${version}:${keyEpoch}`;
91+
const encryptedHash = normalizeOptionalBytes(artifact.encryptedHash);
92+
if (encryptedHash.length !== 32) {
93+
throw new Error("artifact.encryptedHash must be 32 bytes.");
94+
}
95+
const manifestHash = normalizeOptionalBytes(artifact.manifestHash);
96+
if (manifestHash.length !== 32) {
97+
throw new Error("artifact.manifestHash must be 32 bytes.");
98+
}
99+
return {
100+
listingId,
101+
moduleId,
102+
version,
103+
encryptedCid,
104+
encryptedHash: bytesToHex(encryptedHash),
105+
providerId,
106+
policyId,
107+
keyEpoch,
108+
manifestHash: bytesToHex(manifestHash),
109+
contentKeyId,
110+
};
111+
}
112+
113+
export function createMarketplaceContentAad(artifact = {}) {
114+
const binding = normalizeArtifactBinding(artifact);
115+
return canonicalBytes({
116+
encryptedCid: binding.encryptedCid,
117+
encryptedHash: binding.encryptedHash,
118+
keyEpoch: binding.keyEpoch,
119+
listingId: binding.listingId,
120+
manifestHash: binding.manifestHash,
121+
moduleId: binding.moduleId,
122+
policyId: binding.policyId,
123+
providerId: binding.providerId,
124+
version: binding.version,
125+
});
126+
}
127+
128+
function createWrapAad({ artifact, recipient, algorithm }) {
129+
return canonicalBytes({
130+
algorithm,
131+
contentKeyId: artifact.contentKeyId,
132+
encryptedCid: artifact.encryptedCid,
133+
expiresAtMs: Number(recipient.expiresAtMs ?? 0),
134+
grantId: normalizeRequiredString(recipient.grantId, "recipient.grantId"),
135+
keyEpoch: artifact.keyEpoch,
136+
providerId: artifact.providerId,
137+
recipientKeyId: normalizeRequiredString(
138+
recipient.recipientKeyId,
139+
"recipient.recipientKeyId",
140+
),
141+
scope: normalizeRequiredString(recipient.scope, "recipient.scope"),
142+
});
143+
}
144+
145+
function encodeContentKeyMaterial({ keyId, keyBytes, expiresAtMs }) {
146+
const builder = new flatbuffers.Builder(256);
147+
const keyIdOffset = builder.createString(keyId);
148+
const keyBytesOffset = KMF.createKeyBytesVector(builder, keyBytes);
149+
const root = KMF.createKMF(
150+
builder,
151+
keyIdOffset,
152+
keyMaterialRole.PublicationContent,
153+
keyMaterialAlgorithm.Aes256Gcm,
154+
keyMaterialEncoding.RawBytes,
155+
keyBytesOffset,
156+
1,
157+
BigInt(Number(expiresAtMs ?? 0)),
158+
);
159+
KMF.finishKMFBuffer(builder, root);
160+
return builder.asUint8Array();
161+
}
162+
163+
function decodeContentKeyMaterial(bytes) {
164+
const payload = toUint8Array(bytes);
165+
const bb = new flatbuffers.ByteBuffer(payload);
166+
if (!KMF.bufferHasIdentifier(bb)) {
167+
throw new Error("Wrapped marketplace content key payload is not a KMF record.");
168+
}
169+
const kmf = KMF.getRootAsKMF(bb);
170+
if (kmf.ROLE() !== keyMaterialRole.PublicationContent) {
171+
throw new Error("Wrapped marketplace key material is not a publication content key.");
172+
}
173+
if (kmf.ALGORITHM() !== keyMaterialAlgorithm.Aes256Gcm) {
174+
throw new Error("Wrapped marketplace key material is not an AES-256-GCM key.");
175+
}
176+
const keyBytes = kmf.keyBytesArray();
177+
if (!keyBytes || keyBytes.length !== 32) {
178+
throw new Error("Wrapped marketplace content key must be 32 bytes.");
179+
}
180+
return new Uint8Array(keyBytes);
181+
}
182+
56183
export async function generateX25519Keypair() {
57184
const privateKey = await randomBytes(32);
58185
const publicKey = await x25519PublicKey(privateKey);
@@ -62,6 +189,121 @@ export async function generateX25519Keypair() {
62189
};
63190
}
64191

192+
export async function protectMarketplaceContent({
193+
plaintext,
194+
artifact,
195+
recipients = [],
196+
contentKey = null,
197+
contentNonce = null,
198+
providerWrapKeyPair = null,
199+
wrapNonce = null,
200+
} = {}) {
201+
if (!Array.isArray(recipients) || recipients.length === 0) {
202+
throw new Error("protectMarketplaceContent requires at least one recipient.");
203+
}
204+
const binding = normalizeArtifactBinding(artifact);
205+
const keyBytes = contentKey ? toUint8Array(contentKey) : await randomBytes(32);
206+
if (keyBytes.length !== 32) {
207+
throw new Error("Marketplace content keys must be 32 bytes.");
208+
}
209+
const nonce = contentNonce ? toUint8Array(contentNonce) : await randomBytes(12);
210+
if (nonce.length !== 12) {
211+
throw new Error("AES-256-GCM content encryption requires a 12-byte nonce.");
212+
}
213+
const aad = createMarketplaceContentAad(binding);
214+
const encrypted = await aesGcmEncrypt(
215+
keyBytes,
216+
toUint8Array(plaintext),
217+
nonce,
218+
aad,
219+
);
220+
const wrappedKeys = [];
221+
for (const recipient of recipients) {
222+
const sender = providerWrapKeyPair ?? (await generateX25519Keypair());
223+
const recipientPublicKey = normalizePublicKey(recipient.publicKey);
224+
const wrapIv = wrapNonce ? toUint8Array(wrapNonce) : await randomBytes(12);
225+
if (wrapIv.length !== 12) {
226+
throw new Error("AES-256-GCM key wrapping requires a 12-byte nonce.");
227+
}
228+
const wrapAad = createWrapAad({
229+
artifact: binding,
230+
recipient,
231+
algorithm: "X25519-HKDF-SHA256-AES-256-GCM-KMF",
232+
});
233+
const sharedSecret = await deriveSharedSecret(
234+
sender.privateKey,
235+
recipientPublicKey,
236+
);
237+
const wrapContext = `marketplace-content-key:${binding.providerId}:${binding.contentKeyId}:${recipient.recipientKeyId}`;
238+
const wrapKey = await deriveAesKey(sharedSecret, new Uint8Array(0), wrapContext);
239+
const keyMaterial = encodeContentKeyMaterial({
240+
keyId: binding.contentKeyId,
241+
keyBytes,
242+
expiresAtMs: recipient.expiresAtMs,
243+
});
244+
const wrapped = await aesGcmEncrypt(wrapKey, keyMaterial, wrapIv, wrapAad);
245+
wrappedKeys.push({
246+
algorithm: "X25519-HKDF-SHA256-AES-256-GCM-KMF",
247+
contentKeyId: binding.contentKeyId,
248+
recipientPeerId: normalizeRequiredString(
249+
recipient.recipientPeerId,
250+
"recipient.recipientPeerId",
251+
),
252+
recipientKeyId: normalizeRequiredString(
253+
recipient.recipientKeyId,
254+
"recipient.recipientKeyId",
255+
),
256+
providerId: binding.providerId,
257+
encryptedCid: binding.encryptedCid,
258+
grantId: normalizeRequiredString(recipient.grantId, "recipient.grantId"),
259+
scope: normalizeRequiredString(recipient.scope, "recipient.scope"),
260+
expiresAtMs: Number(recipient.expiresAtMs ?? 0),
261+
providerEphemeralPublicKey: sender.publicKey,
262+
nonce: wrapIv,
263+
aad: wrapAad,
264+
ciphertext: wrapped.ciphertext,
265+
tag: wrapped.tag,
266+
});
267+
}
268+
return {
269+
algorithm: "AES-256-GCM",
270+
contentKeyId: binding.contentKeyId,
271+
artifact: binding,
272+
aad,
273+
encryptedPayload: {
274+
algorithm: "AES-256-GCM",
275+
nonce,
276+
aad,
277+
ciphertext: encrypted.ciphertext,
278+
tag: encrypted.tag,
279+
},
280+
wrappedKeys,
281+
};
282+
}
283+
284+
export async function decryptMarketplaceContentKeyWrap({
285+
wrap,
286+
recipientPrivateKey,
287+
} = {}) {
288+
if (!wrap) {
289+
throw new Error("decryptMarketplaceContentKeyWrap requires wrap.");
290+
}
291+
const sharedSecret = await deriveSharedSecret(
292+
recipientPrivateKey,
293+
wrap.providerEphemeralPublicKey,
294+
);
295+
const wrapContext = `marketplace-content-key:${wrap.providerId}:${wrap.contentKeyId}:${wrap.recipientKeyId}`;
296+
const wrapKey = await deriveAesKey(sharedSecret, new Uint8Array(0), wrapContext);
297+
const keyMaterial = await aesGcmDecrypt(
298+
wrapKey,
299+
wrap.ciphertext,
300+
wrap.tag,
301+
wrap.nonce,
302+
wrap.aad,
303+
);
304+
return decodeContentKeyMaterial(keyMaterial);
305+
}
306+
65307
async function encryptBytesLegacy({
66308
plaintext,
67309
recipientPublicKey,

0 commit comments

Comments
 (0)