Skip to content

Commit c8e1fa5

Browse files
NAOR YUVALclaude
authored andcommitted
feat(PR27/PR28): on-chain policy anchoring — anchorRef, XRPL NFT revocation, HCS policy anchoring, encrypted mode
PR27: anchorRef + read-only adapters - Add anchorRef?: string to PolicyGrantLike, CreatePolicyGrantInput, policyGrantForVerificationSchema - New AnchorRail "xrpl-nft", PolicyAnchorResult, PolicyAnchorSubmitMode types - resolveXrplDid: did:xrpl DID resolution via account_objects JSON-RPC - checkXrplNftRevocation: XRPL nft_info RPC — non-existence = revoked - hederaHcsPolicyAnchor: submit policy document to HCS topic (hash-only default) PR28: encrypted anchoring + privacy model - encryptPolicyDocument / decryptPolicyDocument: AES-256-GCM via Web Crypto API - hederaHcsPolicyAnchor: three submitMode values (hash-only/full-document/encrypted) - xrplEncryptAndStorePolicyDocument: encrypt + IPFS upload for NFT-backed grants - InMemoryPolicyCustody: PolicyDocumentCustody for hash-only off-chain storage - 40 new tests across 5 test files (243 total, 27 files, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 340dd5e commit c8e1fa5

19 files changed

Lines changed: 1424 additions & 2 deletions

src/anchor/custody.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Policy document custody — in-memory implementation.
3+
*
4+
* The Service layer (mpcp-policy-authority) is the custodian of full policy documents
5+
* when submitMode="hash-only". Auditors retrieve the document from the Service and
6+
* verify it against the on-chain policyHash.
7+
*
8+
* InMemoryPolicyCustody is for development and testing. Production deployments
9+
* (mpcp-policy-authority) back this interface with a real database.
10+
*/
11+
12+
import type { PolicyDocumentCustody } from "./types.js";
13+
14+
/**
15+
* In-memory policy document custody store.
16+
* Documents are lost when the process exits. Use only for development and testing.
17+
*/
18+
export class InMemoryPolicyCustody implements PolicyDocumentCustody {
19+
private readonly _docs = new Map<string, object>();
20+
21+
async store(policyHash: string, document: object): Promise<void> {
22+
this._docs.set(policyHash, document);
23+
}
24+
25+
async retrieve(policyHash: string): Promise<object | null> {
26+
return this._docs.get(policyHash) ?? null;
27+
}
28+
29+
/** Number of documents currently held. Useful for testing. */
30+
get size(): number {
31+
return this._docs.size;
32+
}
33+
34+
/** Remove all documents. Useful for test teardown. */
35+
clear(): void {
36+
this._docs.clear();
37+
}
38+
}

src/anchor/encrypt.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Shared AES-256-GCM encryption helper for policy document anchoring.
3+
* Uses globalThis.crypto.subtle (Node.js 19+ / all modern browsers).
4+
* No external dependencies.
5+
*/
6+
7+
import type { EncryptedPolicyDocument, PolicyAnchorEncryptionOptions } from "./types.js";
8+
9+
/**
10+
* Copy a Uint8Array into a fresh ArrayBuffer so Web Crypto types are satisfied.
11+
* @types/node types Uint8Array.buffer as ArrayBufferLike (SharedArrayBuffer | ArrayBuffer),
12+
* but SubtleCrypto methods require a concrete ArrayBuffer.
13+
*/
14+
function toArrayBuffer(src: Uint8Array): ArrayBuffer {
15+
return src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength) as ArrayBuffer;
16+
}
17+
18+
/**
19+
* Encrypt a policy document with AES-256-GCM.
20+
*
21+
* @param policyDocument - The policy document object to encrypt
22+
* @param options - Encryption key (Uint8Array or CryptoKey) and optional IV
23+
* @returns EncryptedPolicyDocument with algorithm, base64 IV, and base64 ciphertext
24+
*/
25+
export async function encryptPolicyDocument(
26+
policyDocument: object,
27+
options: PolicyAnchorEncryptionOptions,
28+
): Promise<EncryptedPolicyDocument> {
29+
// Import key if raw bytes provided
30+
let cryptoKey: CryptoKey;
31+
if (options.key instanceof Uint8Array) {
32+
if (options.key.length !== 32) {
33+
throw new Error("AES-256 key must be exactly 32 bytes");
34+
}
35+
cryptoKey = await globalThis.crypto.subtle.importKey(
36+
"raw",
37+
toArrayBuffer(options.key),
38+
{ name: "AES-GCM" },
39+
false,
40+
["encrypt"],
41+
);
42+
} else {
43+
cryptoKey = options.key;
44+
}
45+
46+
const ivRaw = options.iv ?? globalThis.crypto.getRandomValues(new Uint8Array(12));
47+
const iv = toArrayBuffer(ivRaw);
48+
if (ivRaw.length !== 12) {
49+
throw new Error("AES-GCM IV must be exactly 12 bytes");
50+
}
51+
52+
const plaintext = new TextEncoder().encode(JSON.stringify(policyDocument));
53+
54+
// AES-GCM encrypt — ciphertext includes the 16-byte authentication tag appended
55+
const ciphertextBuffer = await globalThis.crypto.subtle.encrypt(
56+
{ name: "AES-GCM", iv },
57+
cryptoKey,
58+
plaintext,
59+
);
60+
61+
return {
62+
algorithm: "AES-256-GCM",
63+
iv: Buffer.from(ivRaw).toString("base64"),
64+
ciphertext: Buffer.from(ciphertextBuffer).toString("base64"),
65+
};
66+
}
67+
68+
/**
69+
* Decrypt an EncryptedPolicyDocument produced by encryptPolicyDocument.
70+
*
71+
* @param encrypted - The encrypted document envelope
72+
* @param options - Decryption key (Uint8Array or CryptoKey)
73+
* @returns The original policy document object
74+
*/
75+
export async function decryptPolicyDocument(
76+
encrypted: EncryptedPolicyDocument,
77+
options: Pick<PolicyAnchorEncryptionOptions, "key">,
78+
): Promise<object> {
79+
let cryptoKey: CryptoKey;
80+
if (options.key instanceof Uint8Array) {
81+
if (options.key.length !== 32) {
82+
throw new Error("AES-256 key must be exactly 32 bytes");
83+
}
84+
cryptoKey = await globalThis.crypto.subtle.importKey(
85+
"raw",
86+
toArrayBuffer(options.key),
87+
{ name: "AES-GCM" },
88+
false,
89+
["decrypt"],
90+
);
91+
} else {
92+
cryptoKey = options.key;
93+
}
94+
95+
const iv = toArrayBuffer(Buffer.from(encrypted.iv, "base64"));
96+
const ciphertext = toArrayBuffer(Buffer.from(encrypted.ciphertext, "base64"));
97+
98+
let plaintext: ArrayBuffer;
99+
try {
100+
plaintext = await globalThis.crypto.subtle.decrypt(
101+
{ name: "AES-GCM", iv },
102+
cryptoKey,
103+
ciphertext,
104+
);
105+
} catch {
106+
throw new Error("decryption_failed: wrong key or corrupted ciphertext");
107+
}
108+
109+
const json = new TextDecoder().decode(plaintext);
110+
return JSON.parse(json) as object;
111+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Hedera HCS policy document anchor.
3+
* Publishes a PolicyGrant's policy document (or its hash) to a Hedera HCS topic
4+
* for tamper-evident, third-party-auditable policy trails.
5+
*
6+
* Default submitMode is "hash-only" — only the policyHash is published.
7+
* This is GDPR-safe and sufficient for most audit use cases.
8+
*
9+
* Environment variables:
10+
* MPCP_HCS_POLICY_TOPIC_ID — Target HCS topic for policy anchoring
11+
* MPCP_HCS_OPERATOR_ID — Hedera operator account ID
12+
* MPCP_HCS_OPERATOR_KEY — Hedera operator private key
13+
* HEDERA_NETWORK — testnet | mainnet (default: testnet)
14+
*/
15+
16+
import { createHash } from "node:crypto";
17+
import type {
18+
PolicyAnchorResult,
19+
PolicyAnchorSubmitMode,
20+
PolicyAnchorEncryptionOptions,
21+
} from "./types.js";
22+
import { encryptPolicyDocument } from "./encrypt.js";
23+
24+
// ---------------------------------------------------------------------------
25+
// HCS message shapes
26+
// ---------------------------------------------------------------------------
27+
28+
interface HcsPolicyAnchorMessageBase {
29+
type: "MPCP:PolicyAnchor:1.0";
30+
policyHash: string;
31+
anchoredAt: string;
32+
}
33+
34+
interface HcsHashOnlyMessage extends HcsPolicyAnchorMessageBase {
35+
submitMode: "hash-only";
36+
}
37+
38+
interface HcsFullDocumentMessage extends HcsPolicyAnchorMessageBase {
39+
submitMode: "full-document";
40+
policyDocument: object;
41+
}
42+
43+
interface HcsEncryptedMessage extends HcsPolicyAnchorMessageBase {
44+
submitMode: "encrypted";
45+
encryptedDocument: {
46+
algorithm: "AES-256-GCM";
47+
iv: string;
48+
ciphertext: string;
49+
};
50+
}
51+
52+
type HcsPolicyAnchorMessage =
53+
| HcsHashOnlyMessage
54+
| HcsFullDocumentMessage
55+
| HcsEncryptedMessage;
56+
57+
// ---------------------------------------------------------------------------
58+
// Main export
59+
// ---------------------------------------------------------------------------
60+
61+
/**
62+
* Anchor a policy document to Hedera HCS.
63+
*
64+
* @param policyDocument - The policy document object to anchor
65+
* @param options - submitMode (default "hash-only"), encryption, and HCS credentials
66+
* @returns PolicyAnchorResult with anchorRef "hcs:{topicId}:{sequenceNumber}"
67+
*/
68+
export async function hederaHcsAnchorPolicyDocument(
69+
policyDocument: object,
70+
options?: {
71+
topicId?: string;
72+
operatorId?: string;
73+
operatorKey?: string;
74+
submitMode?: PolicyAnchorSubmitMode;
75+
encryption?: PolicyAnchorEncryptionOptions;
76+
},
77+
): Promise<PolicyAnchorResult> {
78+
const submitMode: PolicyAnchorSubmitMode = options?.submitMode ?? "hash-only";
79+
80+
if (submitMode === "encrypted" && !options?.encryption) {
81+
throw new Error(
82+
"hederaHcsAnchorPolicyDocument: encryption options required when submitMode is 'encrypted'",
83+
);
84+
}
85+
86+
const accountId = options?.operatorId ?? process.env.MPCP_HCS_OPERATOR_ID;
87+
const privateKeyStr = options?.operatorKey ?? process.env.MPCP_HCS_OPERATOR_KEY;
88+
const topicIdStr = options?.topicId ?? process.env.MPCP_HCS_POLICY_TOPIC_ID;
89+
90+
if (!accountId || !privateKeyStr || !topicIdStr) {
91+
throw new Error(
92+
"HCS policy anchor requires MPCP_HCS_OPERATOR_ID, MPCP_HCS_OPERATOR_KEY, MPCP_HCS_POLICY_TOPIC_ID",
93+
);
94+
}
95+
96+
let sdk: typeof import("@hashgraph/sdk");
97+
try {
98+
sdk = await import("@hashgraph/sdk");
99+
} catch {
100+
throw new Error(
101+
"Hedera HCS requires @hashgraph/sdk. Install with: npm install @hashgraph/sdk",
102+
);
103+
}
104+
const { Client, TopicMessageSubmitTransaction, PrivateKey, AccountId, TopicId } = sdk;
105+
106+
// Compute SHA-256 of canonical policy document
107+
const canonicalPolicy = JSON.stringify(policyDocument, Object.keys(policyDocument).sort());
108+
const policyHash = createHash("sha256").update(canonicalPolicy).digest("hex");
109+
110+
const anchoredAt = new Date().toISOString();
111+
112+
// Build message body based on submitMode
113+
let message: HcsPolicyAnchorMessage;
114+
115+
if (submitMode === "hash-only") {
116+
message = { type: "MPCP:PolicyAnchor:1.0", policyHash, anchoredAt, submitMode };
117+
} else if (submitMode === "full-document") {
118+
message = {
119+
type: "MPCP:PolicyAnchor:1.0",
120+
policyHash,
121+
anchoredAt,
122+
submitMode,
123+
policyDocument,
124+
};
125+
} else {
126+
// encrypted
127+
const encryptedDocument = await encryptPolicyDocument(policyDocument, options!.encryption!);
128+
message = {
129+
type: "MPCP:PolicyAnchor:1.0",
130+
policyHash,
131+
anchoredAt,
132+
submitMode,
133+
encryptedDocument,
134+
};
135+
}
136+
137+
const messageBytes = new TextEncoder().encode(JSON.stringify(message));
138+
139+
const network = process.env.HEDERA_NETWORK ?? "testnet";
140+
const client = Client.forName(network);
141+
client.setOperator(AccountId.fromString(accountId), PrivateKey.fromString(privateKeyStr));
142+
143+
const tx = new TopicMessageSubmitTransaction()
144+
.setTopicId(TopicId.fromString(topicIdStr))
145+
.setMessage(messageBytes);
146+
147+
const txResponse = await tx.execute(client);
148+
const receipt = await txResponse.getReceipt(client);
149+
150+
const sequenceNumber = receipt.topicSequenceNumber?.toString();
151+
const consensusTs = receipt.consensusTimestamp;
152+
const anchoredAtIso = consensusTs ? new Date(consensusTs.toDate()).toISOString() : anchoredAt;
153+
154+
client.close();
155+
156+
return {
157+
rail: "hedera-hcs",
158+
reference: `hcs:${topicIdStr}:${sequenceNumber ?? "unknown"}`,
159+
anchoredAt: anchoredAtIso,
160+
policyHash,
161+
submitMode,
162+
};
163+
}

src/anchor/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,26 @@
44
* for public auditability, dispute protection, and replay protection.
55
*/
66

7-
export type { AnchorOptions, AnchorResult, AnchorRail } from "./types.js";
7+
export type {
8+
AnchorOptions,
9+
AnchorResult,
10+
AnchorRail,
11+
PolicyAnchorResult,
12+
PolicyAnchorSubmitMode,
13+
PolicyAnchorEncryptionOptions,
14+
EncryptedPolicyDocument,
15+
PolicyDocumentIpfsStore,
16+
PolicyDocumentCustody,
17+
} from "./types.js";
818
export { mockAnchorIntentHash } from "./mockAnchor.js";
919
export {
1020
hederaHcsAnchorIntentHash,
1121
verifyHederaHcsAnchor,
1222
} from "./hederaHcsAnchor.js";
23+
export { resolveXrplDid } from "./xrplDid.js";
24+
export { hederaHcsAnchorPolicyDocument } from "./hederaHcsPolicyAnchor.js";
25+
export { checkXrplNftRevocation } from "./xrplNftRevocation.js";
26+
export { xrplEncryptAndStorePolicyDocument } from "./xrplPolicyAnchor.js";
27+
export type { XrplPolicyAnchorPreparation } from "./xrplPolicyAnchor.js";
28+
export { InMemoryPolicyCustody } from "./custody.js";
29+
export { encryptPolicyDocument, decryptPolicyDocument } from "./encrypt.js";

0 commit comments

Comments
 (0)