Skip to content

Commit b949a2c

Browse files
committed
feat(sdk-core): implement deterministic rKey generation for activity claims
1 parent f991cad commit b949a2c

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

.changeset/wise-papayas-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hypercerts-org/sdk-core": minor
3+
---
4+
5+
feat: implement pre-generation of rKeys for activity claims using deterministic content hashing (SHA-256).
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* Crypto utilities for the SDK.
3+
*/
4+
5+
import { NetworkError } from "../core/errors.js";
6+
7+
/**
8+
* Deterministically stringifies an object by sorting keys recursively.
9+
* Handles deeply nested objects and null values correctly.
10+
*/
11+
function stableStringify(obj: unknown): string | undefined {
12+
if (obj === undefined || typeof obj === "function" || typeof obj === "symbol") {
13+
return undefined;
14+
}
15+
if (obj === null || typeof obj !== "object") {
16+
return JSON.stringify(obj);
17+
}
18+
19+
if (Array.isArray(obj)) {
20+
return JSON.stringify(obj.map((item) => {
21+
const val = stableStringify(item);
22+
return val === undefined ? null : JSON.parse(val);
23+
}));
24+
}
25+
26+
const sortedKeys = Object.keys(obj as object).sort();
27+
const sortedObj: Record<string, unknown> = {};
28+
29+
for (const key of sortedKeys) {
30+
const value = (obj as Record<string, unknown>)[key];
31+
const str = stableStringify(value);
32+
// Skip undefined or non-serializable values
33+
if (str === undefined) continue;
34+
sortedObj[key] = JSON.parse(str);
35+
}
36+
37+
return JSON.stringify(sortedObj);
38+
}
39+
40+
/**
41+
* Computes the SHA-256 hash of a JSON-serializable object.
42+
* Returns the hash as a hexadecimal string.
43+
*
44+
* @param content - The content to hash (will be JSON serialized)
45+
* @returns The SHA-256 hash of the content
46+
*/
47+
export async function sha256Hash(content: unknown): Promise<string> {
48+
// Use stable stringification to ensure deterministic output
49+
const jsonString = stableStringify(content) ?? "";
50+
const msgBuffer = new TextEncoder().encode(jsonString);
51+
52+
if (typeof crypto !== "undefined" && crypto.subtle) {
53+
// Browser / Modern Node.js
54+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
55+
const hashArray = Array.from(new Uint8Array(hashBuffer));
56+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
57+
} else {
58+
// Fallback for older environments or specific setups if global crypto isn't available
59+
try {
60+
// Dynamic import to avoid breaking browser builds if bundler doesn't handle it
61+
62+
const { createHash } = await import("node:crypto");
63+
const hash = createHash("sha256").update(jsonString).digest("hex");
64+
return hash;
65+
} catch (e) {
66+
throw new NetworkError("SHA-256 hashing not supported in this environment", e);
67+
}
68+
}
69+
}

packages/sdk-core/src/repository/HypercertOperationsImpl.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
} from "./interfaces.js";
4545
import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js";
4646
import { $Typed } from "@atproto/api";
47+
import { sha256Hash } from "../lib/crypto.js";
4748

4849
/**
4950
* Implementation of high-level hypercert operations.
@@ -288,10 +289,29 @@ export class HypercertOperationsImpl extends EventEmitter<HypercertEvents> imple
288289
throw new ValidationError(`Invalid hypercert record: ${hypercertValidation.error?.message}`);
289290
}
290291

292+
// Generate rKey from stable content hash (idempotency)
293+
// We hash the user's intent (params + rights definition), not the volatile outputs (like new rights CID or createdAt)
294+
295+
// Destructure to remove non-hashable/redundant fields (image, onProgress)
296+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
297+
const { image, onProgress: _onProgress, ...paramsForHash } = params;
298+
299+
const hashInput = {
300+
...paramsForHash,
301+
// Use the full image blob reference for identity (handled by stable stringify)
302+
imageRecord: imageBlobRef,
303+
// Ensure rights definition is part of identity, but not the new CID
304+
rightsData: typeof params.rights === "object" ? params.rights : undefined,
305+
};
306+
307+
const contentHash = await sha256Hash(hashInput);
308+
const rkey = `hc2:${contentHash}`;
309+
291310
const hypercertResult = await this.agent.com.atproto.repo.createRecord({
292311
repo: this.repoDid,
293312
collection: HYPERCERT_COLLECTIONS.CLAIM,
294313
record: hypercertRecord,
314+
rkey,
295315
});
296316

297317
if (!hypercertResult.success) {

0 commit comments

Comments
 (0)