diff --git a/coupling_runtime.exp b/coupling_runtime.exp index 95dd1bd..9b1cd85 100644 Binary files a/coupling_runtime.exp and b/coupling_runtime.exp differ diff --git a/coupling_runtime.lib b/coupling_runtime.lib index 32e9f80..238813e 100644 Binary files a/coupling_runtime.lib and b/coupling_runtime.lib differ diff --git a/src/attestation.native.test.ts b/src/attestation.native.test.ts new file mode 100644 index 0000000..1c06895 --- /dev/null +++ b/src/attestation.native.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { dlopen, FFIType, ptr } from 'bun:ffi'; +import { handleAttestationSubmit, handleCouplingProof } from './attestation'; + +/** + * Cross-implementation channel interop: the REAL native collector DLL (BCrypt ECDH + manual HKDF + + * AES-GCM) seals a payload to a server key we generate; the closed service (Web Crypto) must decrypt + * it. A successful decrypt + matching nonce proves the two independent crypto stacks agree on the + * wire format. Skips automatically when the DLL has not been built on this host. + */ + +const DLL_PATH = fileURLToPath( + new URL( + '../../CreatorAssistant/Verify/Native/coupling-runtime-com/out/win-x64/Release/CouplingRuntimeCom.dll', + import.meta.url + ) +); + +const dllExists = existsSync(DLL_PATH); +const subtle = globalThis.crypto.subtle; + +function b64(bytes: ArrayBuffer | Uint8Array): string { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + return Buffer.from(u8).toString('base64'); +} +function cstr(s: string): Uint8Array { + return new Uint8Array(Buffer.from(`${s}\0`, 'utf8')); +} +function readCstr(buf: Uint8Array): string { + const end = buf.indexOf(0); + return Buffer.from(buf.subarray(0, end < 0 ? buf.length : end)).toString('utf8'); +} + +let originalFetch: typeof fetch; +let lastRelay: { path: string; body: any } | null = null; + +beforeEach(() => { + lastRelay = null; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + lastRelay = { path: new URL(url).pathname, body: JSON.parse(String(init?.body ?? '{}')) }; + return new Response(JSON.stringify({ success: true, blocked: false }), { status: 200 }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe.skipIf(!dllExists)('native-sealed channel decrypts on the closed service', () => { + it('round-trips a real native seal through handleAttestationSubmit', async () => { + // Server attestation keypair: public goes to the native collector, private to the closed decrypt. + const serverEcdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const serverPubSpkiB64 = b64(new Uint8Array(await subtle.exportKey('spki', serverEcdh.publicKey))); + const serverPrivPkcs8B64 = b64(new Uint8Array(await subtle.exportKey('pkcs8', serverEcdh.privateKey))); + + process.env.ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8 = serverPrivPkcs8B64; + process.env.ATTESTATION_FINGERPRINT_SALT = b64(crypto.getRandomValues(new Uint8Array(32))); + process.env.YUCP_TPM_ROOTS_JSON = JSON.stringify([]); // TPM verify will fail; channel is the test + process.env.CONVEX_API_SECRET = 'test-secret'; + process.env.CONVEX_SITE_URL = 'http://control-plane.test'; + + const lib = dlopen(DLL_PATH, { + xg_0201: { + args: [ + FFIType.ptr, + FFIType.ptr, + FFIType.ptr, + FFIType.ptr, + FFIType.ptr, + FFIType.i32, + FFIType.ptr, + ], + returns: FFIType.i32, + }, + }); + + const nonce = 'a'.repeat(64); + const outBuf = new Uint8Array(64 * 1024); + const needed = new Int32Array(1); + + const rc = lib.symbols.xg_0201( + ptr(cstr(serverPubSpkiB64)), + ptr(cstr(nonce)), + ptr(cstr('corr-native')), + ptr(cstr('{}')), + ptr(outBuf), + outBuf.length, + ptr(needed) + ); + lib.close(); + + expect(rc).toBe(0); + const sealedJson = readCstr(outBuf); + const sealed = JSON.parse(sealedJson) as { + ephemeralPublicSpkiB64: string; + ivB64: string; + ciphertextB64: string; + }; + expect(sealed.ephemeralPublicSpkiB64.length).toBeGreaterThan(0); + expect(sealed.ciphertextB64.length).toBeGreaterThan(0); + + // The closed service (Web Crypto) decrypts the BCrypt-sealed channel. Success proves interop. + const result = await handleAttestationSubmit(sealed); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + // The decrypted payload carried our nonce, confirming the channel round-tripped correctly. + expect(lastRelay?.path).toBe('/v1/attestation/internal/record'); + expect(lastRelay?.body.nonce).toBe(nonce); + }); + + it('enforces native self-integrity: pinned hash accepts, unknown hash is rejected', async () => { + const serverEcdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const serverPubSpkiB64 = b64(new Uint8Array(await subtle.exportKey('spki', serverEcdh.publicKey))); + const serverPrivPkcs8B64 = b64(new Uint8Array(await subtle.exportKey('pkcs8', serverEcdh.privateKey))); + process.env.ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8 = serverPrivPkcs8B64; + process.env.ATTESTATION_FINGERPRINT_SALT = b64(crypto.getRandomValues(new Uint8Array(32))); + process.env.YUCP_TPM_ROOTS_JSON = JSON.stringify([]); + process.env.CONVEX_API_SECRET = 'test-secret'; + process.env.CONVEX_SITE_URL = 'http://control-plane.test'; + + // The runtime reports SHA-256 of its own on-disk module; the closed service pins it. + const realHash = createHash('sha256').update(readFileSync(DLL_PATH)).digest('hex'); + + const lib = dlopen(DLL_PATH, { + xg_0201: { + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.ptr], + returns: FFIType.i32, + }, + }); + const nonce = 's'.repeat(64); + const outBuf = new Uint8Array(64 * 1024); + const needed = new Int32Array(1); + const rc = lib.symbols.xg_0201( + ptr(cstr(serverPubSpkiB64)), ptr(cstr(nonce)), ptr(cstr('corr-self')), ptr(cstr('{}')), + ptr(outBuf), outBuf.length, ptr(needed) + ); + lib.close(); + expect(rc).toBe(0); + const sealed = JSON.parse(readCstr(outBuf)) as { + ephemeralPublicSpkiB64: string; ivB64: string; ciphertextB64: string; + }; + + // Pinned to the real runtime hash -> accepted (proves the native emitted the correct self-hash). + process.env.YUCP_RUNTIME_SELF_HASHES_JSON = JSON.stringify([realHash]); + const accepted = await handleAttestationSubmit(sealed); + expect(accepted.ok).toBe(true); + + // Pinned to a different hash -> the runtime looks modified -> rejected, with a generic error. + process.env.YUCP_RUNTIME_SELF_HASHES_JSON = JSON.stringify(['00'.repeat(32)]); + const rejected = await handleAttestationSubmit(sealed); + expect(rejected.ok).toBe(false); + expect(rejected.status).toBe(422); + expect(JSON.stringify(rejected.body)).not.toContain(realHash); + + delete process.env.YUCP_RUNTIME_SELF_HASHES_JSON; + }); + + it('rejects a native coupling proof when no pinned TPM root can authenticate it', async () => { + const serverEcdh = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); + const serverPubSpkiB64 = b64(new Uint8Array(await subtle.exportKey('spki', serverEcdh.publicKey))); + const serverPrivPkcs8B64 = b64(new Uint8Array(await subtle.exportKey('pkcs8', serverEcdh.privateKey))); + process.env.ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8 = serverPrivPkcs8B64; + process.env.ATTESTATION_FINGERPRINT_SALT = b64(crypto.getRandomValues(new Uint8Array(32))); + process.env.YUCP_TPM_ROOTS_JSON = JSON.stringify([]); + delete process.env.YUCP_RUNTIME_SELF_HASHES_JSON; + + const dir = mkdtempSync(join(tmpdir(), 'yucp-coupling-')); + const assetA = join(dir, 'a.bytes'); + const assetB = join(dir, 'b.bytes'); + writeFileSync(assetA, Buffer.from('watermarked-asset-A')); + writeFileSync(assetB, Buffer.from('watermarked-asset-B')); + + const lib = dlopen(DLL_PATH, { + xg_0202: { + args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.i32, FFIType.ptr], + returns: FFIType.i32, + }, + }); + + const seal = (assetPathsNl: string) => { + const outBuf = new Uint8Array(64 * 1024); + const needed = new Int32Array(1); + const rc = lib.symbols.xg_0202( + ptr(cstr(serverPubSpkiB64)), ptr(cstr('a'.repeat(64))), ptr(cstr('corr-coupling')), + ptr(cstr(assetPathsNl)), ptr(outBuf), outBuf.length, ptr(needed) + ); + return { rc, json: readCstr(outBuf) }; + }; + + // The channel and asset hashing path are real, but without a pinned TPM root the proof is not + // authenticated strongly enough to persist forensic asset evidence. + const ok = seal(`${assetA}\n${assetB}`); + expect(ok.rc).toBe(0); + const sealed = JSON.parse(ok.json) as { ephemeralPublicSpkiB64: string; ivB64: string; ciphertextB64: string }; + const rejected = await handleCouplingProof(sealed); + expect(rejected.ok).toBe(false); + expect(rejected.status).toBe(422); + expect(rejected.body).toEqual({ error: 'Coupling proof rejected' }); + expect(lastRelay).toBeNull(); + + // A tampered channel -> decryption fails closed. + const tampered = { ...sealed, ciphertextB64: b64(crypto.getRandomValues(new Uint8Array(48))) }; + const tamperResult = await handleCouplingProof(tampered); + expect(tamperResult.ok).toBe(false); + + // A proof carrying no assets -> rejected. + const empty = seal(''); + lib.close(); + expect(empty.rc).toBe(0); + const emptySealed = JSON.parse(empty.json) as { ephemeralPublicSpkiB64: string; ivB64: string; ciphertextB64: string }; + const emptyResult = await handleCouplingProof(emptySealed); + expect(emptyResult.ok).toBe(false); + + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/attestation.test.ts b/src/attestation.test.ts new file mode 100644 index 0000000..86c8238 --- /dev/null +++ b/src/attestation.test.ts @@ -0,0 +1,601 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { handleAttestationSubmit, handleCouplingProof, handlePaymentAnchor } from "./attestation"; + +/** + * Round-trip tests for the closed coupling-service attestation terminator. + * + * The test "client" seals a payload using the EXACT algorithm the native collector uses + * (P-256 ECDH + HKDF-SHA256 info "yucp.attestation.channel.v1" + AES-256-GCM with ciphertext||tag) + * and signs the challenge nonce with a generated TPM attestation key. This proves the verification + * path is self-consistent and pins the wire format the native AttestationCollector.cpp must produce. + * + * The Convex relay is mocked so we can assert the closed service forwards only salted hashes. + */ + +const CHANNEL_HKDF_INFO = "yucp.attestation.channel.v1"; +const MACHINE_FINGERPRINT = "a604eb0948054b9acb9f40da80a6a4c8e711b98c59e54a11089fea3a2b77dc1c"; +const TEST_TPM_ROOT_PRIVATE_PKCS8_B64 = + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgLnhttZcN3PXYjlD13g0ljMTYNSMfYud1oWKIN93GzY+hRANCAAQsCWnseS3LGEA1CemTvMbY5l79838eo27Nd/WqJDI0ExiWkwJTIMWN96GE5ZSBwZAooEcGpvVinM0NdQdO6Gpd"; +const TEST_TPM_ROOT_PUBLIC_SPKI_B64 = + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELAlp7HktyxhANQnpk7zG2OZe/fN/HqNuzXf1qiQyNBMYlpMCUyDFjfehhOWUgcGQKKBHBqb1YpzNDXUHTuhqXQ=="; +const TEST_TPM_ROOT_CERT_DER_B64 = + "MIIBoDCCAUWgAwIBAgIUSDRzkXcQnj321/BkIclzFuqU7swwCgYIKoZIzj0EAwIwHTEbMBkGA1UEAwwSWVVDUCBUZXN0IFRQTSBSb290MB4XDTI2MDYyNTE3Mzc1OVoXDTM2MDYyMjE3Mzc1OVowHTEbMBkGA1UEAwwSWVVDUCBUZXN0IFRQTSBSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELAlp7HktyxhANQnpk7zG2OZe/fN/HqNuzXf1qiQyNBMYlpMCUyDFjfehhOWUgcGQKKBHBqb1YpzNDXUHTuhqXaNjMGEwHQYDVR0OBBYEFEFj33O6zABNRP0g4ufZ8KN0hlntMB8GA1UdIwQYMBaAFEFj33O6zABNRP0g4ufZ8KN0hlntMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0kAMEYCIQDgASvZWdbgr3Zcz42YA+XzEMrLPdU4bTCXfwQsryvRZwIhAMflMR3HWWJrzeLkkUQ8fQyGHGpsAjDDcC7i71ja3R5w"; +const TEST_EK_CERT_DER_B64 = + "MIIBljCCATygAwIBAgIUTWQEcwt2pCu/8KviIJwIP/Xk8UEwCgYIKoZIzj0EAwIwHTEbMBkGA1UEAwwSWVVDUCBUZXN0IFRQTSBSb290MB4XDTI2MDYyNTE3Mzc1OVoXDTM2MDYyMjE3Mzc1OVowFzEVMBMGA1UEAwwMWVVDUCBUZXN0IEVLMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKLQ1a3eCe9xSj7PUcQisPFkV2hq2mh1GjgAkmPSaloRRdlsT9dGzM8Jw+Jl+oRrAf4VXugu4sKbfKr2dhsEfEaNgMF4wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0OBBYEFEei+QcHePRmrgXPXpfdky6hrCIXMB8GA1UdIwQYMBaAFEFj33O6zABNRP0g4ufZ8KN0hlntMAoGCCqGSM49BAMCA0gAMEUCIQDJ7eZdA7KNamLM1XjPcde6X9ByfyBvRHKkLACS+zxW8QIgGCmZq1fvrlglP+Xutu2tlttC96YmJTM6HMRL4yBcKi4="; +const subtle = globalThis.crypto.subtle; + +function b64(bytes: ArrayBuffer | Uint8Array): string { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + return Buffer.from(u8).toString("base64"); +} +function fromB64(s: string): Uint8Array { + return new Uint8Array(Buffer.from(s, "base64")); +} +function ab(bytes: Uint8Array): ArrayBuffer { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +} +function hex(bytes: Uint8Array): string { + let out = ""; + for (const x of bytes) out += x.toString(16).padStart(2, "0"); + return out; +} +async function sha256Hex(bytes: Uint8Array): Promise { + return hex(new Uint8Array(await subtle.digest("SHA-256", ab(bytes)))); +} + +let serverEcdh: CryptoKeyPair; +let rootEcdsa: CryptoKeyPair; +let akEcdsa: CryptoKeyPair; +let lastRelay: { path: string; body: any } | null = null; +let originalFetch: typeof fetch; +let originalEnv: Record; + +async function sealPayloadLikeNative( + serverPubSpkiB64: string, + payload: object, +) { + // Ephemeral ECDH keypair (the native generates this per submit). + const eph = await subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + const serverPub = await subtle.importKey( + "spki", + ab(fromB64(serverPubSpkiB64)), + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const shared = new Uint8Array( + await subtle.deriveBits( + { name: "ECDH", public: serverPub }, + eph.privateKey, + 256, + ), + ); + const hkdfKey = await subtle.importKey("raw", ab(shared), "HKDF", false, [ + "deriveBits", + ]); + const okm = new Uint8Array( + await subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: ab(new Uint8Array(0)), + info: ab(new TextEncoder().encode(CHANNEL_HKDF_INFO)), + }, + hkdfKey, + 256, + ), + ); + const aesKey = await subtle.importKey( + "raw", + ab(okm), + { name: "AES-GCM" }, + false, + ["encrypt"], + ); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array( + await subtle.encrypt( + { name: "AES-GCM", iv: ab(iv) }, + aesKey, + ab(new TextEncoder().encode(JSON.stringify(payload))), + ), + ); + const ephSpki = new Uint8Array(await subtle.exportKey("spki", eph.publicKey)); + return { + ephemeralPublicSpkiB64: b64(ephSpki), + ivB64: b64(iv), + ciphertextB64: b64(ct), + }; +} + +async function buildTpmFields(nonce: string) { + const akSpki = new Uint8Array( + await subtle.exportKey("spki", akEcdsa.publicKey), + ); + const akPublicSpkiB64 = b64(akSpki); + const challengeSig = new Uint8Array( + await subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + akEcdsa.privateKey, + ab(new TextEncoder().encode(nonce)), + ), + ); + const akDigest = await sha256Hex(akSpki); + const certifyBlob = new TextEncoder().encode( + `yucp.akcertify.v1|${akDigest}|${nonce}`, + ); + const akCertifySig = new Uint8Array( + await subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + rootEcdsa.privateKey, + ab(certifyBlob), + ), + ); + return { + akPublicSpkiB64, + challengeSigB64: b64(challengeSig), + ekCertDerB64: TEST_EK_CERT_DER_B64, + akCertifyB64: b64(certifyBlob), + akCertifySigB64: b64(akCertifySig), + }; +} + +function canonicalCouplingProofSignatureMessage(payload: { + nonce: string; + correlationId: string; + selfModuleSha256?: string; + couplingAssets: Array<{ path: string; sha256: string }>; +}): string { + return JSON.stringify([ + "yucp.coupling-proof.v1", + payload.nonce, + payload.correlationId, + payload.selfModuleSha256?.trim().toLowerCase() ?? "", + payload.couplingAssets.map((asset) => [ + asset.path.trim(), + asset.sha256.trim().toLowerCase(), + ]), + ]); +} + +async function buildCouplingProofTpmFields(payload: { + nonce: string; + correlationId: string; + selfModuleSha256?: string; + couplingAssets: Array<{ path: string; sha256: string }>; +}) { + const fields = await buildTpmFields(payload.nonce); + const proofSig = new Uint8Array( + await subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + akEcdsa.privateKey, + ab(new TextEncoder().encode(canonicalCouplingProofSignatureMessage(payload))), + ), + ); + return { ...fields, challengeSigB64: b64(proofSig) }; +} + +function basePayload(nonce: string) { + return { + nonce, + timestamp: Date.now(), + correlationId: "corr-test", + components: [{ component: "cpuid", value: "GenuineRaw", weight: 3 }], + osAnchors: ["machineguid:abc"], + flags: [] as string[], + licenseSubject: "lic-test", + machineFingerprint: MACHINE_FINGERPRINT, + }; +} + +beforeEach(async () => { + originalEnv = { + ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8: + process.env.ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8, + ATTESTATION_FINGERPRINT_SALT: process.env.ATTESTATION_FINGERPRINT_SALT, + YUCP_TPM_ROOTS_JSON: process.env.YUCP_TPM_ROOTS_JSON, + CONVEX_API_SECRET: process.env.CONVEX_API_SECRET, + CONVEX_SITE_URL: process.env.CONVEX_SITE_URL, + COUPLING_CONTROL_PLANE_BASE_URL: + process.env.COUPLING_CONTROL_PLANE_BASE_URL, + CONTROL_PLANE_BASE_URL: process.env.CONTROL_PLANE_BASE_URL, + YUCP_RUNTIME_SELF_HASHES_JSON: process.env.YUCP_RUNTIME_SELF_HASHES_JSON, + }; + serverEcdh = await subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + true, + ["deriveBits"], + ); + rootEcdsa = { + privateKey: await subtle.importKey( + "pkcs8", + ab(fromB64(TEST_TPM_ROOT_PRIVATE_PKCS8_B64)), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ), + publicKey: await subtle.importKey( + "spki", + ab(fromB64(TEST_TPM_ROOT_PUBLIC_SPKI_B64)), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ), + }; + akEcdsa = await subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + + const serverPrivPkcs8 = new Uint8Array( + await subtle.exportKey("pkcs8", serverEcdh.privateKey), + ); + + process.env.ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8 = b64(serverPrivPkcs8); + process.env.ATTESTATION_FINGERPRINT_SALT = b64( + crypto.getRandomValues(new Uint8Array(32)), + ); + process.env.YUCP_TPM_ROOTS_JSON = JSON.stringify([TEST_TPM_ROOT_CERT_DER_B64]); + process.env.CONVEX_API_SECRET = "test-secret"; + process.env.CONVEX_SITE_URL = "http://control-plane.test"; + + lastRelay = null; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + lastRelay = { + path: new URL(url).pathname, + body: JSON.parse(String(init?.body ?? "{}")), + }; + return new Response(JSON.stringify({ success: true, blocked: false }), { + status: 200, + }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("closed-service attestation round trip", () => { + it("verifies a well-formed TPM attestation and forwards only hashes", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "a".repeat(64); + const payload = { + ...basePayload(nonce), + tpmPresent: true, + ...(await buildTpmFields(nonce)), + }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + + const result = await handleAttestationSubmit(sealed); + + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + const body = result.body as { success: boolean; blocked: boolean }; + // The client response exposes only success + verdict; detection state is server-side only. + expect(body.success).toBe(true); + expect(body.blocked).toBe(false); + expect(body).not.toHaveProperty("tpmVerified"); + expect(body).not.toHaveProperty("flags"); + + // The relay must receive hashes, never the raw values. Detection state is recorded here. + expect(lastRelay?.path).toBe("/v1/attestation/internal/record"); + expect(lastRelay?.body.nonce).toBe(nonce); + const att = lastRelay?.body.attestation; + expect(att.tpmVerified).toBe(true); + expect(att.flags).not.toContain("no_tpm"); + expect(att.flags).not.toContain("spoof_suspected"); + expect(att.licenseSubject).toBe("lic-test"); + expect(att.machineFingerprintHash).toBe( + await sha256Hex(new TextEncoder().encode(MACHINE_FINGERPRINT)), + ); + expect(JSON.stringify(lastRelay?.body)).not.toContain(MACHINE_FINGERPRINT); + expect(att.fingerprintVector[0].hash).not.toBe("GenuineRaw"); + expect(att.fingerprintVector[0].hash).toMatch(/^[0-9a-f]{64}$/); + expect( + lastRelay?.body.anchors.some( + (a: { anchorType: string }) => a.anchorType === "tpm_ek", + ), + ).toBe(true); + }); + + it("uses closed-service component weights instead of client supplied weights", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "f".repeat(64); + const payload = { + ...basePayload(nonce), + tpmPresent: false, + components: [{ component: "cpuid", value: "GenuineRaw", weight: 999 }], + }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + + const result = await handleAttestationSubmit(sealed); + + expect(result.ok).toBe(true); + expect(lastRelay?.body.attestation.fingerprintVector[0].weight).toBe(3); + }); + + it("keeps the TPM EK anchor stable when only the AK rotates", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const firstNonce = "1".repeat(64); + const firstSealed = await sealPayloadLikeNative(serverPubSpkiB64, { + ...basePayload(firstNonce), + tpmPresent: true, + ...(await buildTpmFields(firstNonce)), + }); + + const firstResult = await handleAttestationSubmit(firstSealed); + expect(firstResult.ok).toBe(true); + const firstEkAnchor = lastRelay?.body.anchors.find( + (anchor: { anchorType: string }) => anchor.anchorType === "tpm_ek", + )?.anchorHash; + expect(firstEkAnchor).toMatch(/^[0-9a-f]{64}$/); + + akEcdsa = await subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const secondNonce = "2".repeat(64); + const secondSealed = await sealPayloadLikeNative(serverPubSpkiB64, { + ...basePayload(secondNonce), + tpmPresent: true, + ...(await buildTpmFields(secondNonce)), + }); + + const secondResult = await handleAttestationSubmit(secondSealed); + expect(secondResult.ok).toBe(true); + const secondEkAnchor = lastRelay?.body.anchors.find( + (anchor: { anchorType: string }) => anchor.anchorType === "tpm_ek", + )?.anchorHash; + expect(secondEkAnchor).toBe(firstEkAnchor); + }); + + it("flags spoof_suspected when the TPM signature does not verify", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "b".repeat(64); + const tpm = await buildTpmFields(nonce); + // Corrupt the challenge signature. + const badSig = fromB64(tpm.challengeSigB64); + badSig[0] = (badSig[0] ?? 0) ^ 0xff; + const payload = { + ...basePayload(nonce), + tpmPresent: true, + ...tpm, + challengeSigB64: b64(badSig), + }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + + const result = await handleAttestationSubmit(sealed); + expect(result.ok).toBe(true); + const att = lastRelay?.body.attestation; + expect(att.tpmVerified).toBe(false); + expect(att.flags).toContain("spoof_suspected"); + }); + + it("flags no_tpm and still records when no TPM is present", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "c".repeat(64); + const payload = { ...basePayload(nonce), tpmPresent: false }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + + const result = await handleAttestationSubmit(sealed); + expect(result.ok).toBe(true); + const att = lastRelay?.body.attestation; + expect(att.tpmVerified).toBe(false); + expect(att.flags).toContain("no_tpm"); + // The tpm_ek anchor must be absent when TPM did not verify. + expect( + lastRelay?.body.anchors.some( + (a: { anchorType: string }) => a.anchorType === "tpm_ek", + ), + ).toBe(false); + }); + + it("rejects a stale timestamp", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "d".repeat(64); + const payload = { + ...basePayload(nonce), + timestamp: Date.now() - 10 * 60 * 1000, + tpmPresent: true, + ...(await buildTpmFields(nonce)), + }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + + const result = await handleAttestationSubmit(sealed); + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + }); + + it("rejects malformed array fields without relaying or throwing", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "e".repeat(64); + const malformedPayloads = [ + (() => { + const { components: _components, ...payload } = { + ...basePayload(nonce), + tpmPresent: false, + }; + return payload; + })(), + { + ...basePayload(nonce), + tpmPresent: false, + osAnchors: "machineguid:abc", + }, + ]; + + for (const payload of malformedPayloads) { + lastRelay = null; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, payload); + const result = await handleAttestationSubmit(sealed); + + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + expect(result.body).toEqual({ error: "Malformed attestation payload" }); + expect(lastRelay).toBeNull(); + } + }); + + it("rejects an undecryptable channel", async () => { + const result = await handleAttestationSubmit({ + ephemeralPublicSpkiB64: b64(new TextEncoder().encode("not-a-key")), + ivB64: b64(new Uint8Array(12)), + ciphertextB64: b64(new Uint8Array(32)), + }); + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + }); +}); + +describe("closed-service coupling proof", () => { + it("rejects no-TPM proofs because the coupling assets are unauthenticated", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "5".repeat(64); + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, { + type: "coupling-proof", + nonce, + timestamp: Date.now(), + correlationId: "corr-proof-no-tpm", + tpmPresent: false, + selfModuleSha256: "a".repeat(64), + couplingAssets: [ + { path: "Assets/Test/model.fbx", sha256: "b".repeat(64) }, + ], + }); + + const result = await handleCouplingProof(sealed); + + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + expect(result.body).toEqual({ error: "Coupling proof rejected" }); + expect(lastRelay).toBeNull(); + }); + + it("rejects malformed asset hashes before relaying", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "6".repeat(64); + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, { + type: "coupling-proof", + nonce, + timestamp: Date.now(), + correlationId: "corr-proof", + tpmPresent: false, + couplingAssets: [{ path: "Assets/Test/model.fbx", sha256: "not-a-sha" }], + }); + + const result = await handleCouplingProof(sealed); + + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + expect(result.body).toEqual({ error: "Malformed coupling proof assets" }); + expect(lastRelay).toBeNull(); + }); + + it("records a TPM-authenticated coupling proof", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "8".repeat(64); + const proof = { + type: "coupling-proof", + nonce, + timestamp: Date.now(), + correlationId: "corr-proof-authenticated", + tpmPresent: true, + selfModuleSha256: "a".repeat(64), + couplingAssets: [ + { path: "Assets/Test/model.fbx", sha256: "b".repeat(64) }, + ], + }; + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, { + ...proof, + ...(await buildCouplingProofTpmFields(proof)), + }); + + const result = await handleCouplingProof(sealed); + + expect(result.ok).toBe(true); + expect(lastRelay?.path).toBe("/v1/attestation/internal/coupling-record"); + expect(lastRelay?.body.proof.tpmVerified).toBe(true); + expect(lastRelay?.body.proof.flags).toEqual([]); + }); + + it("rejects a proof when the TPM signature only covers the nonce", async () => { + const serverPubSpkiB64 = b64( + new Uint8Array(await subtle.exportKey("spki", serverEcdh.publicKey)), + ); + const nonce = "7".repeat(64); + const sealed = await sealPayloadLikeNative(serverPubSpkiB64, { + type: "coupling-proof", + nonce, + timestamp: Date.now(), + correlationId: "corr-proof-bound", + tpmPresent: true, + ...(await buildTpmFields(nonce)), + selfModuleSha256: "a".repeat(64), + couplingAssets: [ + { path: "Assets/Test/model.fbx", sha256: "b".repeat(64) }, + ], + }); + + const result = await handleCouplingProof(sealed); + + expect(result.ok).toBe(false); + expect(result.status).toBe(422); + expect(result.body).toEqual({ error: "Coupling proof rejected" }); + expect(lastRelay).toBeNull(); + }); +}); + +describe("closed-service payment anchor", () => { + it("salts the payment fingerprint and relays only the hash", async () => { + const result = await handlePaymentAnchor("lic-test", "card-4242-raw"); + expect(result.ok).toBe(true); + expect(lastRelay?.path).toBe("/v1/attestation/internal/payment-anchor"); + expect(lastRelay?.body.licenseSubject).toBe("lic-test"); + // The raw fingerprint must never be relayed; only a 64-hex salted hash. + expect(lastRelay?.body.paymentFingerprintHash).not.toBe("card-4242-raw"); + expect(lastRelay?.body.paymentFingerprintHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("rejects missing inputs", async () => { + const result = await handlePaymentAnchor("", "card"); + expect(result.ok).toBe(false); + expect(result.status).toBe(400); + }); +}); diff --git a/src/attestation.ts b/src/attestation.ts new file mode 100644 index 0000000..7ff90e0 --- /dev/null +++ b/src/attestation.ts @@ -0,0 +1,871 @@ +import { X509Certificate, createPublicKey } from "node:crypto"; +import type { KeyObject } from "node:crypto"; + +/** + * Hardware-attested anti-ripper identity: CLOSED coupling-service terminator. + * + * This file lives in the closed, non-open-source service on purpose. It holds every attestation + * secret and every revealing heuristic: the channel private key, the fingerprint salt, the pinned + * TPM manufacturer roots, the TPM verification, the component weighting, and the block-eligibility + * policy. The open-source Convex server never sees any of this; it only stores salted hashes and an + * opaque verdict that this service computes, via the CONVEX_API_SECRET-guarded relay endpoints. + * + * Flow: + * 1. handleChallenge: ask the open server to mint a single-use nonce (anti-replay), return it. + * 2. handleSubmit: decrypt the sealed channel, verify the TPM AK signature over the nonce and the + * EK chain, salt and hash every raw component, decide block eligibility, then hand the open + * server only opaque hashes plus the verdict to persist and resolve. + * + * Crypto uses Web Crypto (globalThis.crypto.subtle), available in the Bun runtime. No raw identifier + * is ever logged or returned to the client. + */ + +const CHANNEL_HKDF_INFO = "yucp.attestation.channel.v1"; +const FINGERPRINT_HASH_PURPOSE = "attestation-fingerprint"; +const ANCHOR_HASH_PURPOSE = "identity-node-anchor"; +const ATTESTATION_MAX_SKEW_MS = 120 * 1000; +/** A block may rest only on durable anchors, and needs at least this many corroborating. */ +const MIN_DURABLE_ANCHORS_FOR_BLOCK = 2; +const RELAY_TIMEOUT_MS = 5_000; +const MAX_COMPONENTS = 64; +const MAX_COMPONENT_NAME_LENGTH = 64; +const MAX_COMPONENT_VALUE_LENGTH = 2048; +const MAX_OS_ANCHORS = 32; +const MAX_ANCHOR_VALUE_LENGTH = 1024; +const MAX_COUPLING_PROOF_ASSETS = 512; +const MAX_COUPLING_PROOF_PATH_LENGTH = 1024; +const SHA256_HEX_RE = /^[0-9a-f]{64}$/i; + +const COMPONENT_WEIGHTS = new Map([ + ["cpuid", 3], + ["bios", 2], + ["board", 2], + ["disk", 2], + ["gpu", 1], + ["os", 1], +]); + +type AttestationFlag = "no_tpm" | "spoof_suspected" | "vm" | "low_confidence"; + +interface SubmitPayload { + nonce: string; + timestamp: number; + correlationId: string; + tpmPresent: boolean; + akPublicSpkiB64?: string; + ekCertDerB64?: string; + challengeSigB64?: string; + akCertifyB64?: string; + akCertifySigB64?: string; + components: Array<{ component: string; value: string; weight?: number }>; + osAnchors: string[]; + networkAnchor?: string; + usrId?: string; + usrIdConfidence?: string; + licenseSubject?: string; + machineFingerprint?: string; + authUserId?: string; + selfModuleSha256?: string; + selfAuthenticodeThumbprint?: string; + flags: string[]; +} + +function requireEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`${name} is not configured`); + } + return value; +} + +function base64ToBytes(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, "base64")); +} + +function bytesToHex(bytes: Uint8Array): string { + let hex = ""; + for (const b of bytes) hex += b.toString(16).padStart(2, "0"); + return hex; +} + +function ab(bytes: Uint8Array): ArrayBuffer { + const out = new ArrayBuffer(bytes.byteLength); + new Uint8Array(out).set(bytes); + return out; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + return bytesToHex( + new Uint8Array(await crypto.subtle.digest("SHA-256", ab(bytes))), + ); +} + +/** Domain-separated salted hash. Stable for matching; the salt never leaves this service. */ +async function saltedHash(purpose: string, value: string): Promise { + const salt = base64ToBytes(requireEnv("ATTESTATION_FINGERPRINT_SALT")); + const ikm = new TextEncoder().encode(`${purpose}|${value}`); + const baseKey = await crypto.subtle.importKey("raw", ab(ikm), "HKDF", false, [ + "deriveBits", + ]); + const bits = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: ab(salt), + info: ab(new TextEncoder().encode(purpose)), + }, + baseKey, + 256, + ); + return bytesToHex(new Uint8Array(bits)); +} + +async function decryptChannel( + ephemeralPublicSpkiB64: string, + ivB64: string, + ciphertextB64: string, +): Promise { + const serverPriv = await crypto.subtle.importKey( + "pkcs8", + ab(base64ToBytes(requireEnv("ATTESTATION_CHANNEL_PRIVATE_KEY_PKCS8"))), + { name: "ECDH", namedCurve: "P-256" }, + false, + ["deriveBits"], + ); + const clientPub = await crypto.subtle.importKey( + "spki", + ab(base64ToBytes(ephemeralPublicSpkiB64)), + { name: "ECDH", namedCurve: "P-256" }, + false, + [], + ); + const sharedBits = new Uint8Array( + await crypto.subtle.deriveBits( + { name: "ECDH", public: clientPub }, + serverPriv, + 256, + ), + ); + const hkdfKey = await crypto.subtle.importKey( + "raw", + ab(sharedBits), + "HKDF", + false, + ["deriveBits"], + ); + const okm = new Uint8Array( + await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: ab(new Uint8Array(0)), + info: ab(new TextEncoder().encode(CHANNEL_HKDF_INFO)), + }, + hkdfKey, + 256, + ), + ); + const aesKey = await crypto.subtle.importKey( + "raw", + ab(okm), + { name: "AES-GCM" }, + false, + ["decrypt"], + ); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: ab(base64ToBytes(ivB64)) }, + aesKey, + ab(base64ToBytes(ciphertextB64)), + ); + return new TextDecoder().decode(plaintext); +} + +async function verifyAkSignatureOverNonce( + akPublicSpkiB64: string, + challengeSigB64: string, + nonce: string, +): Promise { + return await verifyAkSignatureOverMessage( + akPublicSpkiB64, + challengeSigB64, + nonce, + ); +} + +async function verifyAkSignatureOverMessage( + akPublicSpkiB64: string, + signatureB64: string, + message: string, +): Promise { + try { + const akPub = await crypto.subtle.importKey( + "spki", + ab(base64ToBytes(akPublicSpkiB64)), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + akPub, + ab(base64ToBytes(signatureB64)), + ab(new TextEncoder().encode(message)), + ); + } catch { + return false; + } +} + +function loadPinnedTpmRoots(): string[] { + const raw = process.env.YUCP_TPM_ROOTS_JSON; + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) + ? parsed.filter((x): x is string => typeof x === "string") + : []; + } catch { + return []; + } +} + +type PinnedTpmRoot = { + publicKey: KeyObject; + spkiDer: Uint8Array; +}; + +function loadPinnedTpmRootKeys(): PinnedTpmRoot[] { + const roots: PinnedTpmRoot[] = []; + for (const rootB64 of loadPinnedTpmRoots()) { + try { + const der = Buffer.from(rootB64, "base64"); + try { + const cert = new X509Certificate(der); + const spkiDer = cert.publicKey.export({ + type: "spki", + format: "der", + }) as Buffer; + roots.push({ + publicKey: cert.publicKey, + spkiDer: new Uint8Array(spkiDer), + }); + continue; + } catch { + const publicKey = createPublicKey({ + key: der, + format: "der", + type: "spki", + }); + roots.push({ publicKey, spkiDer: new Uint8Array(der) }); + } + } catch { + // Ignore malformed pinned roots; a valid root is required for TPM verification. + } + } + return roots; +} + +function isCertificateTimeValid(cert: X509Certificate): boolean { + const now = Date.now(); + const validFrom = Date.parse(cert.validFrom); + const validTo = Date.parse(cert.validTo); + return Number.isFinite(validFrom) && Number.isFinite(validTo) && validFrom <= now && now <= validTo; +} + +function verifyEkCertificateChain(ekCertDerB64: string, roots: PinnedTpmRoot[]): boolean { + if (roots.length === 0) return false; + try { + const ekCert = new X509Certificate(Buffer.from(ekCertDerB64, "base64")); + if (!isCertificateTimeValid(ekCert)) return false; + return roots.some((root) => { + try { + return ekCert.verify(root.publicKey); + } catch { + return false; + } + }); + } catch { + return false; + } +} + +// Pinned set of allowed runtime self-integrity hashes (lowercase hex SHA-256 of the shipped module). +// Opt-in: when configured, a runtime whose self-hash is absent/unknown is rejected as tampered. +function loadPinnedRuntimeSelfHashes(): Set { + const raw = process.env.YUCP_RUNTIME_SELF_HASHES_JSON; + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed + .filter((x): x is string => typeof x === "string") + .map((x) => x.trim().toLowerCase()), + ); + } catch { + return new Set(); + } +} + +function serverComponentWeight(component: string): number { + return COMPONENT_WEIGHTS.get(component.trim().toLowerCase()) ?? 1; +} + +function hasValidSubmitArrays(payload: SubmitPayload): boolean { + return ( + Array.isArray(payload.components) && + payload.components.length > 0 && + payload.components.length <= MAX_COMPONENTS && + payload.components.every( + (component) => + component && + typeof component.component === "string" && + component.component.trim().length > 0 && + component.component.trim().length <= MAX_COMPONENT_NAME_LENGTH && + typeof component.value === "string" && + component.value.length > 0 && + component.value.length <= MAX_COMPONENT_VALUE_LENGTH, + ) && + Array.isArray(payload.osAnchors) && + payload.osAnchors.length <= MAX_OS_ANCHORS && + payload.osAnchors.every( + (anchor) => + typeof anchor === "string" && + anchor.trim().length > 0 && + anchor.length <= MAX_ANCHOR_VALUE_LENGTH, + ) + ); +} + +function hasValidMachineFingerprint(value: unknown): value is string { + return typeof value === "string" && value.trim().length >= 16 && value.length <= 512; +} + +async function verifyEkChainAndAkBinding( + payload: SubmitPayload, +): Promise { + if ( + !payload.ekCertDerB64 || + !payload.akCertifyB64 || + !payload.akCertifySigB64 || + !payload.akPublicSpkiB64 + ) { + return false; + } + const roots = loadPinnedTpmRootKeys(); + if (roots.length === 0) { + return false; + } + if (!verifyEkCertificateChain(payload.ekCertDerB64, roots)) { + return false; + } + const akPubDigest = await sha256Hex(base64ToBytes(payload.akPublicSpkiB64)); + const certifyBlob = new TextDecoder().decode( + base64ToBytes(payload.akCertifyB64), + ); + if (!certifyBlob.includes(akPubDigest)) { + return false; + } + for (const root of roots) { + try { + const rootPub = await crypto.subtle.importKey( + "spki", + ab(root.spkiDer), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + const verified = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + rootPub, + ab(base64ToBytes(payload.akCertifySigB64)), + ab(base64ToBytes(payload.akCertifyB64)), + ); + if (verified) return true; + } catch { + // next root + } + } + return false; +} + +function controlPlaneBaseUrl(): string { + const url = + process.env.COUPLING_CONTROL_PLANE_BASE_URL?.trim() || + process.env.CONTROL_PLANE_BASE_URL?.trim() || + process.env.CONVEX_SITE_URL?.trim(); + if (!url) { + throw new Error("Control plane base URL is not configured"); + } + return url.replace(/\/$/, ""); +} + +async function relay(path: string, body: unknown): Promise { + const secret = requireEnv("CONVEX_API_SECRET"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS); + try { + return await fetch(`${controlPlaneBaseUrl()}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${secret}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +/** Public: importer asks for a challenge. We proxy nonce minting to the open store. */ +export async function handleAttestationChallenge( + correlationId: string, +): Promise<{ + ok: boolean; + status: number; + body: unknown; +}> { + if (!correlationId || correlationId.length > 128) { + return { + ok: false, + status: 400, + body: { error: "correlationId is required" }, + }; + } + const res = await relay("/v1/attestation/internal/challenge", { + correlationId, + }); + if (!res.ok) { + return { ok: false, status: 502, body: { error: "Challenge mint failed" } }; + } + const data = (await res.json()) as { nonce: string; expiresAt: number }; + return { + ok: true, + status: 200, + body: { nonce: data.nonce, expiresAt: data.expiresAt }, + }; +} + +/** Public: importer submits the sealed attestation. We verify, hash, decide, then persist opaquely. */ +export async function handleAttestationSubmit(sealed: { + ephemeralPublicSpkiB64: string; + ivB64: string; + ciphertextB64: string; +}): Promise<{ ok: boolean; status: number; body: unknown }> { + if ( + !sealed.ephemeralPublicSpkiB64 || + !sealed.ivB64 || + !sealed.ciphertextB64 + ) { + return { + ok: false, + status: 400, + body: { error: "sealed channel fields are required" }, + }; + } + + let decrypted: string; + try { + decrypted = await decryptChannel( + sealed.ephemeralPublicSpkiB64, + sealed.ivB64, + sealed.ciphertextB64, + ); + } catch { + return { + ok: false, + status: 422, + body: { error: "Channel decryption failed" }, + }; + } + + let payload: SubmitPayload; + try { + payload = JSON.parse(decrypted) as SubmitPayload; + } catch { + return { + ok: false, + status: 422, + body: { error: "Malformed attestation payload" }, + }; + } + + if (!payload.nonce) { + return { + ok: false, + status: 422, + body: { error: "Missing challenge nonce" }, + }; + } + if ( + !Number.isFinite(payload.timestamp) || + Math.abs(Date.now() - payload.timestamp) > ATTESTATION_MAX_SKEW_MS + ) { + return { + ok: false, + status: 422, + body: { error: "Attestation timestamp out of range" }, + }; + } + if (!hasValidSubmitArrays(payload)) { + return { + ok: false, + status: 422, + body: { error: "Malformed attestation payload" }, + }; + } + if ( + payload.machineFingerprint !== undefined && + !hasValidMachineFingerprint(payload.machineFingerprint) + ) { + return { + ok: false, + status: 422, + body: { error: "Malformed attestation payload" }, + }; + } + + // Self-integrity gate (anti-tamper): when a pinned runtime hash set is configured, a runtime whose + // own module hash is missing or unknown is a modified/repacked binary and is rejected outright. The + // error is generic so it does not reveal the expected hash or that this specific check fired. + const pinnedSelfHashes = loadPinnedRuntimeSelfHashes(); + if (pinnedSelfHashes.size > 0) { + const selfHash = payload.selfModuleSha256?.trim().toLowerCase(); + if (!selfHash || !pinnedSelfHashes.has(selfHash)) { + return { + ok: false, + status: 422, + body: { error: "Attestation payload rejected" }, + }; + } + } + + const flags = new Set( + (Array.isArray(payload.flags) ? payload.flags : []).filter( + (f): f is AttestationFlag => + f === "no_tpm" || + f === "spoof_suspected" || + f === "vm" || + f === "low_confidence", + ), + ); + + let tpmVerified = false; + if ( + payload.tpmPresent && + payload.akPublicSpkiB64 && + payload.challengeSigB64 + ) { + const sigOk = await verifyAkSignatureOverNonce( + payload.akPublicSpkiB64, + payload.challengeSigB64, + payload.nonce, + ); + const chainOk = await verifyEkChainAndAkBinding(payload); + tpmVerified = sigOk && chainOk; + if (!tpmVerified) { + flags.add("spoof_suspected"); + } + } else { + flags.add("no_tpm"); + } + + // Salt and hash every raw value here, in the closed service. Only hashes leave. + const fingerprintVector = await Promise.all( + payload.components.map(async (c) => ({ + component: c.component, + hash: await saltedHash( + FINGERPRINT_HASH_PURPOSE, + `${c.component}:${c.value}`, + ), + weight: serverComponentWeight(c.component), + })), + ); + const osAnchorHashes = await Promise.all( + payload.osAnchors.map((value) => + saltedHash(ANCHOR_HASH_PURPOSE, `os_machine:${value}`), + ), + ); + const usrIdHash = payload.usrId + ? await saltedHash(ANCHOR_HASH_PURPOSE, `usr:${payload.usrId}`) + : undefined; + const networkAnchorHash = payload.networkAnchor + ? await saltedHash(ANCHOR_HASH_PURPOSE, `network:${payload.networkAnchor}`) + : undefined; + const machineFingerprintHash = payload.machineFingerprint + ? await sha256Hex(new TextEncoder().encode(payload.machineFingerprint.trim())) + : undefined; + const ekHash = + tpmVerified && payload.ekCertDerB64 + ? await saltedHash( + ANCHOR_HASH_PURPOSE, + `tpm_ek:${payload.ekCertDerB64}`, + ) + : undefined; + const akPubHash = payload.akPublicSpkiB64 + ? await saltedHash(ANCHOR_HASH_PURPOSE, `akpub:${payload.akPublicSpkiB64}`) + : undefined; + + const anchors: Array<{ anchorType: string; anchorHash: string }> = []; + if (ekHash) anchors.push({ anchorType: "tpm_ek", anchorHash: ekHash }); + if (usrIdHash) anchors.push({ anchorType: "usr", anchorHash: usrIdHash }); + for (const h of osAnchorHashes) + anchors.push({ anchorType: "os_machine", anchorHash: h }); + + const res = await relay("/v1/attestation/internal/record", { + nonce: payload.nonce, + anchors, + attestation: { + ekHash, + akPubHash, + tpmVerified, + flags: Array.from(flags), + fingerprintVector, + osAnchorHashes, + networkAnchorHash, + usrIdHash, + licenseSubject: payload.licenseSubject, + machineFingerprintHash, + authUserId: payload.authUserId, + usrIdConfidence: payload.usrIdConfidence, + correlationId: payload.correlationId, + }, + }); + if (!res.ok) { + const message = + res.status === 422 + ? "Challenge already used or expired" + : "Persist failed"; + return { + ok: false, + status: res.status === 422 ? 422 : 502, + body: { error: message }, + }; + } + const data = (await res.json()) as { blocked: boolean }; + + // The client receives only what it must act on: success and the block verdict. Detection state + // (tpmVerified, spoof/vm/no_tpm flags) is recorded server-side via the relay above and never + // returned, so an attacker cannot read back whether a spoof or VM was detected to tune evasion. + return { + ok: true, + status: 200, + body: { + success: true, + blocked: data.blocked, + }, + }; +} + +interface CouplingProofPayload { + type?: string; + nonce: string; + timestamp: number; + correlationId: string; + tpmPresent: boolean; + akPublicSpkiB64?: string; + ekCertDerB64?: string; + challengeSigB64?: string; + akCertifyB64?: string; + akCertifySigB64?: string; + selfModuleSha256?: string; + couplingAssets: Array<{ path: string; sha256: string }>; +} + +function hasValidCouplingAssets( + payload: CouplingProofPayload, +): payload is CouplingProofPayload & { + couplingAssets: Array<{ path: string; sha256: string }>; +} { + return ( + Array.isArray(payload.couplingAssets) && + payload.couplingAssets.length > 0 && + payload.couplingAssets.length <= MAX_COUPLING_PROOF_ASSETS && + payload.couplingAssets.every( + (asset) => + asset && + typeof asset.path === "string" && + asset.path.trim().length > 0 && + asset.path.length <= MAX_COUPLING_PROOF_PATH_LENGTH && + typeof asset.sha256 === "string" && + SHA256_HEX_RE.test(asset.sha256.trim()), + ) + ); +} + +function canonicalCouplingProofSignatureMessage(payload: CouplingProofPayload): string { + return JSON.stringify([ + "yucp.coupling-proof.v1", + payload.nonce, + payload.correlationId, + payload.selfModuleSha256?.trim().toLowerCase() ?? "", + payload.couplingAssets.map((asset) => [ + asset.path.trim(), + asset.sha256.trim().toLowerCase(), + ]), + ]); +} + +/** + * Verify a sealed coupling proof emitted by the native runtime after it watermarks the assets. The + * proof binds a fresh nonce, the resulting asset content hashes, the runtime self-integrity hash, and + * the TPM AK signature over the nonce. The importer only relays the opaque blob, so a patched importer + * that skips coupling cannot fabricate a valid proof. Returns the verified asset hashes (hashes only; + * the raw error never echoes a secret). Anti-replay nonce consumption + persistence are wired at the + * HTTP route layer (reusing the challenge-consume relay), the same shape as attestation submit. + */ +export async function handleCouplingProof(sealed: { + ephemeralPublicSpkiB64: string; + ivB64: string; + ciphertextB64: string; +}): Promise<{ ok: boolean; status: number; body: unknown }> { + if (!sealed.ephemeralPublicSpkiB64 || !sealed.ivB64 || !sealed.ciphertextB64) { + return { ok: false, status: 400, body: { error: "sealed channel fields are required" } }; + } + + let decrypted: string; + try { + decrypted = await decryptChannel( + sealed.ephemeralPublicSpkiB64, + sealed.ivB64, + sealed.ciphertextB64, + ); + } catch { + return { ok: false, status: 422, body: { error: "Channel decryption failed" } }; + } + + let payload: CouplingProofPayload; + try { + payload = JSON.parse(decrypted) as CouplingProofPayload; + } catch { + return { ok: false, status: 422, body: { error: "Malformed coupling proof" } }; + } + + if (!payload.nonce || payload.type !== "coupling-proof") { + return { ok: false, status: 422, body: { error: "Coupling proof rejected" } }; + } + if ( + !Number.isFinite(payload.timestamp) || + Math.abs(Date.now() - payload.timestamp) > ATTESTATION_MAX_SKEW_MS + ) { + return { ok: false, status: 422, body: { error: "Coupling proof timestamp out of range" } }; + } + if (!hasValidCouplingAssets(payload)) { + return { ok: false, status: 422, body: { error: "Malformed coupling proof assets" } }; + } + + // Self-integrity gate: a modified/repacked runtime is rejected when a pinned set is configured. + const pinnedSelfHashes = loadPinnedRuntimeSelfHashes(); + if (pinnedSelfHashes.size > 0) { + const selfHash = payload.selfModuleSha256?.trim().toLowerCase(); + if (!selfHash || !pinnedSelfHashes.has(selfHash)) { + return { ok: false, status: 422, body: { error: "Coupling proof rejected" } }; + } + } + + // Coupling proof assets are accepted only when the runtime covers them with the TPM-backed + // signature. Attestation submits may persist no-TPM evidence for review, but proof persistence is a + // write to the forensic asset record and must fail closed when the asset list is unauthenticated. + const flags: AttestationFlag[] = []; + let tpmVerified = false; + if (payload.tpmPresent && payload.akPublicSpkiB64 && payload.challengeSigB64) { + const sigOk = await verifyAkSignatureOverMessage( + payload.akPublicSpkiB64, + payload.challengeSigB64, + canonicalCouplingProofSignatureMessage(payload), + ); + const chainOk = await verifyEkChainAndAkBinding({ + ekCertDerB64: payload.ekCertDerB64, + akCertifyB64: payload.akCertifyB64, + akCertifySigB64: payload.akCertifySigB64, + akPublicSpkiB64: payload.akPublicSpkiB64, + } as SubmitPayload); + tpmVerified = sigOk && chainOk; + if (!tpmVerified) flags.push("spoof_suspected"); + } else { + flags.push("no_tpm"); + } + if (!tpmVerified) { + return { ok: false, status: 422, body: { error: "Coupling proof rejected" } }; + } + + // Salt/hash here, in the closed service, so only opaque values leave. The content SHA-256 is a + // hash of the resulting bytes (reveals nothing about the watermark algorithm); paths are salted. + const assets = await Promise.all( + payload.couplingAssets.map(async (a) => ({ + pathHash: await saltedHash(ANCHOR_HASH_PURPOSE, `coupling-path:${a.path.trim()}`), + contentSha256: a.sha256.trim().toLowerCase(), + })), + ); + const selfHashRef = payload.selfModuleSha256 + ? await saltedHash(ANCHOR_HASH_PURPOSE, `self:${payload.selfModuleSha256}`) + : undefined; + + // Consume the nonce (anti-replay) and persist via the secret-guarded relay. A replayed proof is + // rejected here because its nonce is already consumed. + const res = await relay("/v1/attestation/internal/coupling-record", { + nonce: payload.nonce, + proof: { + correlationId: payload.correlationId, + tpmVerified, + flags, + assets, + selfHashRef, + }, + }); + if (!res.ok) { + return { + ok: false, + status: res.status === 422 ? 422 : 502, + body: { + error: res.status === 422 ? "Coupling proof already used or expired" : "Persist failed", + }, + }; + } + + // Opaque result: success only, never detection state (matches the attestation submit surface). + return { ok: true, status: 200, body: { success: true } }; +} + +/** + * Called by the open-server provider webhook (Gumroad/Jinxxy) with the raw payment instrument + * fingerprint. We salt it here, in the closed service, then attach the opaque hash as a durable + * anchor on the buyer's identity node via the open relay. The open server never sees the salt. + */ +export async function handlePaymentAnchor( + licenseSubject: string, + paymentFingerprint: string, +): Promise<{ ok: boolean; status: number; body: unknown }> { + if (!licenseSubject || !paymentFingerprint) { + return { + ok: false, + status: 400, + body: { error: "licenseSubject and paymentFingerprint required" }, + }; + } + const paymentFingerprintHash = await saltedHash( + ANCHOR_HASH_PURPOSE, + `payment:${paymentFingerprint}`, + ); + const res = await relay("/v1/attestation/internal/payment-anchor", { + licenseSubject, + paymentFingerprintHash, + }); + if (!res.ok) { + return { + ok: false, + status: 502, + body: { error: "Payment anchor relay failed" }, + }; + } + const data = (await res.json()) as { attached: boolean }; + return { + ok: true, + status: 200, + body: { success: true, attached: data.attached }, + }; +} + +export const __policy = { MIN_DURABLE_ANCHORS_FOR_BLOCK }; diff --git a/src/config.ts b/src/config.ts index 565c378..fc5b1ea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -108,6 +108,7 @@ export type CouplingServiceConfig = { brokerSharedSecret: string | undefined; dllPath: string; dllSha256: string | undefined; + wmMasterKeyHex: string | undefined; workRoot: string; maxAssetsPerRequest: number; maxFileBytes: number; @@ -147,6 +148,9 @@ export const config: CouplingServiceConfig = { ), dllSha256: readFirstEnv('COUPLING_RUNTIME_LIBRARY_SHA256', 'COUPLING_RUNTIME_DLL_SHA256') || undefined, + // Watermark master key (hex). The per-job placement seed is HKDF(master, asset|subject|epoch). + // Lives only in this closed service — never compiled into the client. + wmMasterKeyHex: readFirstEnv('COUPLING_WM_MASTER_KEY') || undefined, workRoot: resolveConfiguredPath('COUPLING_WORK_ROOT', defaultWorkRoot), maxAssetsPerRequest: readIntegerEnv('COUPLING_MAX_ASSETS_PER_REQUEST', 512), maxFileBytes: readIntegerEnv('COUPLING_MAX_FILE_BYTES', 64 * 1024 * 1024), diff --git a/src/couplingSeed.test.ts b/src/couplingSeed.test.ts new file mode 100644 index 0000000..d7bbb8c --- /dev/null +++ b/src/couplingSeed.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; + +process.env.COUPLING_SERVICE_SECRET = process.env.COUPLING_SERVICE_SECRET || 'seed-test-secret'; +process.env.COUPLING_WM_MASTER_KEY = + '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; + +const { deriveCouplingSeedHex, isCouplingSeedConfigured, COUPLING_SEED_HEX_LENGTH } = await import( + './couplingSeed' +); + +describe('coupling seed derivation', () => { + it('reports configured when the master key is set', () => { + expect(isCouplingSeedConfigured()).toBe(true); + }); + + it('is deterministic for the same (asset, subject, epoch)', async () => { + const a = await deriveCouplingSeedHex('Assets/x.png', 'buyer-1'); + const b = await deriveCouplingSeedHex('Assets/x.png', 'buyer-1'); + expect(a).toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + expect(a.length).toBe(COUPLING_SEED_HEX_LENGTH); + }); + + it('differs by buyer, asset, and epoch (per-install placement)', async () => { + const base = await deriveCouplingSeedHex('Assets/x.png', 'buyer-1', 0); + expect(await deriveCouplingSeedHex('Assets/x.png', 'buyer-2', 0)).not.toBe(base); + expect(await deriveCouplingSeedHex('Assets/y.png', 'buyer-1', 0)).not.toBe(base); + expect(await deriveCouplingSeedHex('Assets/x.png', 'buyer-1', 1)).not.toBe(base); + }); + + it('does not collide when fields contain legacy delimiter characters', async () => { + const first = await deriveCouplingSeedHex('Assets/a|buyer.png', 'subject', 0); + const second = await deriveCouplingSeedHex('Assets/a', 'buyer.png|subject', 0); + + expect(first).not.toBe(second); + }); +}); diff --git a/src/couplingSeed.ts b/src/couplingSeed.ts new file mode 100644 index 0000000..35afb17 --- /dev/null +++ b/src/couplingSeed.ts @@ -0,0 +1,70 @@ +import { config } from './config'; + +/** + * Per-job watermark placement seed. The closed service holds the master key; the seed handed to a + * client is HKDF(master, structured asset/subject/epoch context) so it is unique per (asset, buyer) + * and exposes only that one install if the client is cracked. No key is compiled into the client. + * Forensics re-derives the same seed on demand from (asset path, candidate buyer subject); nothing + * is stored. + */ + +export const COUPLING_SEED_EPOCH = 0; +export const COUPLING_SEED_HEX_LENGTH = 64; + +function hexToBytes(hex: string): Uint8Array { + const clean = hex.trim().toLowerCase(); + if (!/^[0-9a-f]+$/.test(clean) || clean.length % 2 !== 0) { + throw new Error('COUPLING_WM_MASTER_KEY must be even-length hex'); + } + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i += 1) { + out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function ab(view: Uint8Array): ArrayBuffer { + return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; +} + +export function isCouplingSeedConfigured(): boolean { + return Boolean(config.wmMasterKeyHex?.trim()); +} + +function encodeSeedInfo(assetId: string, licenseSubject: string, epoch: number): Uint8Array { + return new TextEncoder().encode( + JSON.stringify(['yucp.wm.seed.v1', assetId, licenseSubject, epoch]) + ); +} + +/** Derive the 64-hex placement seed for one (asset, buyer). Throws if the master key is unset. */ +export async function deriveCouplingSeedHex( + assetId: string, + licenseSubject: string, + epoch: number = COUPLING_SEED_EPOCH +): Promise { + const masterHex = config.wmMasterKeyHex?.trim(); + if (!masterHex) { + throw new Error('COUPLING_WM_MASTER_KEY is not configured'); + } + if (masterHex.length < 32) { + throw new Error('COUPLING_WM_MASTER_KEY must be at least 16 bytes of hex'); + } + const master = hexToBytes(masterHex); + const key = await crypto.subtle.importKey('raw', ab(master), 'HKDF', false, ['deriveBits']); + const info = encodeSeedInfo(assetId, licenseSubject, epoch); + const okm = new Uint8Array( + await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: ab(new Uint8Array(0)), info: ab(info) }, + key, + 256 + ) + ); + return bytesToHex(okm); +} diff --git a/src/ffi.test.ts b/src/ffi.test.ts index 284f2ff..d8c5e4c 100644 --- a/src/ffi.test.ts +++ b/src/ffi.test.ts @@ -4,9 +4,12 @@ import path from 'node:path'; process.env.COUPLING_SERVICE_SECRET = process.env.COUPLING_SERVICE_SECRET ?? 'ffi-test-secret'; const { config } = await import('./config'); -const { getCouplingRuntimeHealth, normalizeTokenHexForFfi, resetNativeRuntimeForTests } = await import( - './ffi' -); +const { + getCouplingRuntimeHealth, + normalizeSeedHexForFfi, + normalizeTokenHexForFfi, + resetNativeRuntimeForTests, +} = await import('./ffi'); const originalDllPath = config.dllPath; const originalDllSha256 = config.dllSha256; @@ -63,3 +66,18 @@ describe('normalizeTokenHexForFfi', () => { expect(JSON.stringify(health)).not.toContain('missing-native-runtime.dll'); }); }); + +describe('normalizeSeedHexForFfi', () => { + it('lowercases valid seed hex before sending it to the native runtime', () => { + expect(normalizeSeedHexForFfi('A'.repeat(64))).toBe('a'.repeat(64)); + }); + + it('rejects seed values outside the native seed contract', () => { + expect(() => normalizeSeedHexForFfi('abcd')).toThrow( + 'seedHex must be 64 lowercase hex characters' + ); + expect(() => normalizeSeedHexForFfi('g'.repeat(64))).toThrow( + 'seedHex must be 64 lowercase hex characters' + ); + }); +}); diff --git a/src/ffi.ts b/src/ffi.ts index b178b71..ef540fe 100644 --- a/src/ffi.ts +++ b/src/ffi.ts @@ -9,18 +9,25 @@ export type CouplingDecodeInput = { filePath: string; assetType: CouplingAssetType; expectedTokenLength?: number; + // Per-job seed (64 hex) that placed the v2 mark. Required to decode a v2 image/mesh mark; when + // absent, only the legacy (pre-seed) decoders are attempted. + seedHex?: string; }; export type CouplingEncodeInput = { filePath: string; assetType: Exclude; tokenHex: string; + // Per-job seed (64 hex) from the server; the sole placement secret (no key is baked into the client). + seedHex: string; }; export type CouplingDecodeResult = { decoderKind: string; tokenHex: string; tokenLength: number; + // 2 = v2 (seeded DCT/vertex-norm), 1 = legacy/text. Lets forensics tell which engine matched. + wmVersion: number; }; type NativeSymbols = { @@ -34,6 +41,12 @@ type NativeSymbols = { outHexUtf8: number, outCap: number ) => number; + // v2 image (DCT-domain QIM, all raster formats). Encode(path, token, seed): 0 ok, 1 skip, <0 err. + xg_0122: (filePathUtf8: number, tokenHexUtf8: number, seedHexUtf8: number) => number; + xg_0123: (filePathUtf8: number, seedHexUtf8: number, outHexUtf8: number, outCap: number) => number; + // v2 FBX mesh (vertex-norm). Same (path, token, seed) / (path, seed, out, cap) shape. + xg_0124: (filePathUtf8: number, tokenHexUtf8: number, seedHexUtf8: number) => number; + xg_0125: (filePathUtf8: number, seedHexUtf8: number, outHexUtf8: number, outCap: number) => number; }; type NativeLibrary = { @@ -43,6 +56,7 @@ type NativeLibrary = { const MIN_TOKEN_HEX_LENGTH = 8; const MAX_TOKEN_HEX_LENGTH = 64; +const SEEDED_TOKEN_HEX_LENGTH = 16; const OUTPUT_CAPACITY = MAX_TOKEN_HEX_LENGTH + 1; const HEX_RE = /^[0-9a-f]+$/; @@ -185,6 +199,22 @@ function loadNativeLibrary(): NativeLibrary { args: ['ptr', 'u32', 'ptr', 'u32'], returns: 'i32', }, + xg_0122: { + args: ['ptr', 'ptr', 'ptr'], + returns: 'i32', + }, + xg_0123: { + args: ['ptr', 'ptr', 'ptr', 'u32'], + returns: 'i32', + }, + xg_0124: { + args: ['ptr', 'ptr', 'ptr'], + returns: 'i32', + }, + xg_0125: { + args: ['ptr', 'ptr', 'ptr', 'u32'], + returns: 'i32', + }, }) as unknown as NativeLibrary; } catch { throw new NativeRuntimeLoadError('load_failed', 'Coupling runtime library failed to load'); @@ -222,6 +252,16 @@ export function normalizeTokenHexForFfi(tokenHex: string): string { return normalized; } +const SEED_HEX_LENGTH = 64; + +export function normalizeSeedHexForFfi(seedHex: string): string { + const normalized = (seedHex ?? '').trim().toLowerCase(); + if (!normalized || !HEX_RE.test(normalized) || normalized.length !== SEED_HEX_LENGTH) { + throw new Error('seedHex must be 64 lowercase hex characters'); + } + return normalized; +} + function toNativeUtf8Buffer(value: string): Buffer { return Buffer.from(`${value}\0`, 'utf8'); } @@ -262,13 +302,32 @@ function callNativeDecoder(input: CouplingDecodeInput): CouplingDecodeResult { const filePathBuffer = toNativeUtf8Buffer(input.filePath); const filePathPointer = ptr(filePathBuffer); const decoderKind = getDecoderKind(input.assetType); + const seedHex = input.seedHex ? normalizeSeedHexForFfi(input.seedHex) : undefined; + + // A v2 mark can only be read with the per-job seed that placed it; without one we go straight to + // the legacy decoders. + const seedPointer = seedHex ? ptr(toNativeUtf8Buffer(seedHex)) : null; let exitCode: number; if (input.assetType === 'png') { + if (seedPointer !== null) { + exitCode = library.symbols.xg_0123(filePathPointer, seedPointer, outputPointer, output.byteLength); + if (exitCode === 0) { + const tokenHex = decodeBufferToHex(output); + return { decoderKind: 'coupling-png-v2-ffi', tokenHex, tokenLength: tokenHex.length, wmVersion: 2 }; + } + } exitCode = library.symbols.xg_0118(filePathPointer, outputPointer, output.byteLength); } else if (input.assetType === 'text') { exitCode = library.symbols.xg_0119(filePathPointer, outputPointer, output.byteLength); } else { + if (seedPointer !== null) { + exitCode = library.symbols.xg_0125(filePathPointer, seedPointer, outputPointer, output.byteLength); + if (exitCode === 0) { + const tokenHex = decodeBufferToHex(output); + return { decoderKind: 'coupling-fbx-v2-ffi', tokenHex, tokenLength: tokenHex.length, wmVersion: 2 }; + } + } const expectedTokenLength = input.expectedTokenLength ?? getDefaultExpectedTokenLength('fbx'); if (!expectedTokenLength) { throw new Error(`Missing expected token length for ${decoderKind}`); @@ -291,6 +350,7 @@ function callNativeDecoder(input: CouplingDecodeInput): CouplingDecodeResult { decoderKind, tokenHex, tokenLength: tokenHex.length, + wmVersion: 1, }; } @@ -300,16 +360,30 @@ export function decodeCouplingAsset(input: CouplingDecodeInput): CouplingDecodeR export function encodeCouplingAsset(input: CouplingEncodeInput): void { const tokenHex = normalizeTokenHexForFfi(input.tokenHex); + const seedHex = normalizeSeedHexForFfi(input.seedHex); const library = loadNativeLibrary(); const filePathPointer = ptr(toNativeUtf8Buffer(input.filePath)); const tokenHexPointer = ptr(toNativeUtf8Buffer(tokenHex)); + const seedHexPointer = ptr(toNativeUtf8Buffer(seedHex)); + const useSeededV2 = tokenHex.length === SEEDED_TOKEN_HEX_LENGTH; const decoderKind = - input.assetType === 'png' ? 'coupling-png-encode-ffi' : 'coupling-fbx-encode-ffi'; + input.assetType === 'png' + ? useSeededV2 + ? 'coupling-png-v2-encode-ffi' + : 'coupling-png-legacy-encode-ffi' + : useSeededV2 + ? 'coupling-fbx-v2-encode-ffi' + : 'coupling-fbx-legacy-encode-ffi'; const exitCode = input.assetType === 'png' - ? library.symbols.xg_0115(filePathPointer, tokenHexPointer) - : library.symbols.xg_0120(filePathPointer, tokenHexPointer); - if (exitCode !== 0) { + ? useSeededV2 + ? library.symbols.xg_0122(filePathPointer, tokenHexPointer, seedHexPointer) + : library.symbols.xg_0115(filePathPointer, tokenHexPointer) + : useSeededV2 + ? library.symbols.xg_0124(filePathPointer, tokenHexPointer, seedHexPointer) + : library.symbols.xg_0120(filePathPointer, tokenHexPointer); + // 0 = applied, 1 = skipped (uncarryable asset — never an error, never blocks), <0 = real failure. + if (exitCode !== 0 && exitCode !== 1) { throw new NativeCouplingError( decoderKind, exitCode, diff --git a/src/materialize.test.ts b/src/materialize.test.ts index fb2d1ed..df77501 100644 --- a/src/materialize.test.ts +++ b/src/materialize.test.ts @@ -78,7 +78,9 @@ function createMaterializationFetchStub(args: { descriptor: { protectedAssetId?: string; ciphertextSha256?: string }; contentKeyBase64: string; couplingJobs?: Array<{ assetPath: string; tokenHex: string }>; + licenseSubject?: string; redeemCounts: Map; + receiptCounts?: Map; }) { return async (input: string | URL | Request, init?: RequestInit): Promise => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; @@ -101,11 +103,14 @@ function createMaterializationFetchStub(args: { contentKeyBase64: args.contentKeyBase64, contentHash: args.descriptor.ciphertextSha256, couplingJobs: args.couplingJobs ?? [], + ...(args.licenseSubject === undefined ? {} : { licenseSubject: args.licenseSubject }), expiresAt: Math.floor(Date.now() / 1000) + 300, }); } if (url.endsWith('/v1/licenses/protected-materialization-receipt')) { + const grant = body.grant ?? ''; + args.receiptCounts?.set(grant, (args.receiptCounts.get(grant) ?? 0) + 1); return Response.json({ success: true }); } @@ -267,6 +272,169 @@ describe('materializeProtectedPayload', () => { } }); + it('fails closed when a coupling job is present without a license subject for seed derivation', async () => { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-materialize-')); + try { + const fixture = await createMaterializationFixture(workspaceDir); + const redeemCounts = new Map(); + const receiptCounts = new Map(); + + await mkdir(path.join(workspaceDir, 'service-work'), { recursive: true }); + Object.assign(config as Record, { + workRoot: path.join(workspaceDir, 'service-work'), + wmMasterKeyHex: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', + }); + globalThis.fetch = createMaterializationFetchStub({ + descriptor: fixture.descriptor, + contentKeyBase64: fixture.contentKeyBase64, + couplingJobs: [{ assetPath: 'Assets/Protected/artifact.png', tokenHex: '0123456789abcdef' }], + redeemCounts, + receiptCounts, + }) as typeof fetch; + + await expect( + materializeProtectedPayload({ + grant: 'grant-missing-license-subject', + blobFilePath: fixture.blobFilePath, + projectRoot: fixture.projectRoot, + descriptor: fixture.descriptor, + }) + ).rejects.toThrow('coupling jobs require configured seeds and a license subject'); + + expect(receiptCounts.get('grant-missing-license-subject')).toBeUndefined(); + await expect( + readFile(path.join(fixture.projectRoot, 'Assets', 'Protected', 'artifact.png')) + ).rejects.toThrow(); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it('validates coupling seeds before publishing decrypted assets', async () => { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-materialize-')); + const previousFetch = globalThis.fetch; + const previousWorkRoot = config.workRoot; + const previousWmMasterKeyHex = config.wmMasterKeyHex; + try { + const fixture = await createMaterializationFixture(workspaceDir); + const redeemCounts = new Map(); + const receiptCounts = new Map(); + + await mkdir(path.join(workspaceDir, 'service-work'), { recursive: true }); + Object.assign(config as Record, { + workRoot: path.join(workspaceDir, 'service-work'), + wmMasterKeyHex: 'z'.repeat(32), + }); + globalThis.fetch = createMaterializationFetchStub({ + descriptor: fixture.descriptor, + contentKeyBase64: fixture.contentKeyBase64, + couplingJobs: [{ assetPath: 'Assets/Protected/artifact.png', tokenHex: '0123456789abcdef' }], + licenseSubject: 'lic-test', + redeemCounts, + receiptCounts, + }) as typeof fetch; + + await expect( + materializeProtectedPayload({ + grant: 'grant-malformed-seed-key', + blobFilePath: fixture.blobFilePath, + projectRoot: fixture.projectRoot, + descriptor: fixture.descriptor, + }) + ).rejects.toThrow('COUPLING_WM_MASTER_KEY'); + + expect(receiptCounts.get('grant-malformed-seed-key')).toBeUndefined(); + await expect( + readFile(path.join(fixture.projectRoot, 'Assets', 'Protected', 'artifact.png')) + ).rejects.toThrow(); + } finally { + globalThis.fetch = previousFetch; + Object.assign(config as Record, { + workRoot: previousWorkRoot, + wmMasterKeyHex: previousWmMasterKeyHex, + }); + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it('validates coupling job token syntax before publishing decrypted assets', async () => { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-materialize-')); + try { + const fixture = await createMaterializationFixture(workspaceDir); + const redeemCounts = new Map(); + const receiptCounts = new Map(); + + await mkdir(path.join(workspaceDir, 'service-work'), { recursive: true }); + Object.assign(config as Record, { + workRoot: path.join(workspaceDir, 'service-work'), + wmMasterKeyHex: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', + }); + globalThis.fetch = createMaterializationFetchStub({ + descriptor: fixture.descriptor, + contentKeyBase64: fixture.contentKeyBase64, + couplingJobs: [{ assetPath: 'Assets/Protected/artifact.png', tokenHex: 'not-a-token' }], + licenseSubject: 'lic-test', + redeemCounts, + receiptCounts, + }) as typeof fetch; + + await expect( + materializeProtectedPayload({ + grant: 'grant-malformed-token', + blobFilePath: fixture.blobFilePath, + projectRoot: fixture.projectRoot, + descriptor: fixture.descriptor, + }) + ).rejects.toThrow('tokenHex'); + + expect(receiptCounts.get('grant-malformed-token')).toBeUndefined(); + await expect( + readFile(path.join(fixture.projectRoot, 'Assets', 'Protected', 'artifact.png')) + ).rejects.toThrow(); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it('materializes legacy-length coupling job tokens without forcing v2 encoding', async () => { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-materialize-')); + try { + const fixture = await createMaterializationFixture(workspaceDir); + const redeemCounts = new Map(); + const receiptCounts = new Map(); + + await mkdir(path.join(workspaceDir, 'service-work'), { recursive: true }); + Object.assign(config as Record, { + workRoot: path.join(workspaceDir, 'service-work'), + wmMasterKeyHex: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', + }); + globalThis.fetch = createMaterializationFetchStub({ + descriptor: fixture.descriptor, + contentKeyBase64: fixture.contentKeyBase64, + couplingJobs: [{ assetPath: 'Assets/Protected/artifact.png', tokenHex: 'a'.repeat(32) }], + licenseSubject: 'lic-test', + redeemCounts, + receiptCounts, + }) as typeof fetch; + + await expect( + materializeProtectedPayload({ + grant: 'grant-legacy-token', + blobFilePath: fixture.blobFilePath, + projectRoot: fixture.projectRoot, + descriptor: fixture.descriptor, + }) + ).resolves.toEqual({ materializedAssetPaths: ['Assets/Protected/artifact.png'] }); + + expect(receiptCounts.get('grant-legacy-token')).toBe(1); + await expect( + readFile(path.join(fixture.projectRoot, 'Assets', 'Protected', 'artifact.png')) + ).resolves.toBeInstanceOf(Buffer); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + it('removes delete-me materialization logs from the service sources', async () => { const materializeSource = await Bun.file(path.join(import.meta.dir, 'materialize.ts')).text(); const serverSource = await Bun.file(path.join(import.meta.dir, 'server.ts')).text(); diff --git a/src/materialize.ts b/src/materialize.ts index f7e9187..c79c24f 100644 --- a/src/materialize.ts +++ b/src/materialize.ts @@ -2,7 +2,8 @@ import { createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node: import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { config } from './config'; -import { encodeCouplingAsset } from './ffi'; +import { encodeCouplingAsset, normalizeTokenHexForFfi } from './ffi'; +import { deriveCouplingSeedHex, isCouplingSeedConfigured } from './couplingSeed'; import { extractZipArchiveBytes } from './zipArchive'; export type BrokeredProtectedPayloadDescriptor = { @@ -520,6 +521,32 @@ export async function materializeProtectedPayload(args: { throw new Error('Protected materialization grant did not include a content key'); } + // Server-side embedding derives the same per-(asset, buyer) seed forensics will re-derive. A + // queued coupling job means the signed payload expects marking, so missing seed material or buyer + // identity is a hard failure before decrypted files are published into the project. + const licenseSubject = redeemed.licenseSubject?.trim(); + const seedAvailable = isCouplingSeedConfigured() && Boolean(licenseSubject); + const couplingJobs = (redeemed.couplingJobs ?? []).map((job) => ({ + ...job, + assetPath: assertSafeProjectRelativePath(job.assetPath), + })); + if (couplingJobs.length > 0 && !seedAvailable) { + throw new Error('Protected materialization coupling jobs require configured seeds and a license subject'); + } + for (const job of couplingJobs) { + if (!expectedAssetPathSet.has(job.assetPath)) { + throw new Error('Protected materialization coupling job target did not match the signed payload descriptor'); + } + } + const couplingJobSeeds: Array<(typeof couplingJobs)[number] & { seedHex: string }> = []; + for (const job of couplingJobs) { + couplingJobSeeds.push({ + ...job, + tokenHex: normalizeTokenHexForFfi(job.tokenHex), + seedHex: await deriveCouplingSeedHex(job.assetPath, licenseSubject as string), + }); + } + await mkdir(config.workRoot, { recursive: true }); const tempRoot = await mkdtemp(path.join(config.workRoot, 'materialize-')); try { @@ -530,25 +557,20 @@ export async function materializeProtectedPayload(args: { redeemed.contentKeyBase64 ); await expandArchive(archiveBytes, extractRoot, expectedAssetPaths); + for (const job of couplingJobSeeds) { + encodeCouplingAsset({ + filePath: resolveProjectPath(extractRoot, job.assetPath), + assetType: inferEncodableAssetType(job.assetPath), + tokenHex: job.tokenHex, + seedHex: job.seedHex, + }); + } const materializedAssetPaths = await copyExtractedAssets( extractRoot, projectRoot, expectedAssetPaths ); - for (const job of redeemed.couplingJobs ?? []) { - const safeAssetPath = assertSafeProjectRelativePath(job.assetPath); - if (!expectedAssetPathSet.has(safeAssetPath)) { - throw new Error('Protected materialization coupling job target did not match the signed payload descriptor'); - } - - encodeCouplingAsset({ - filePath: resolveProjectPath(projectRoot, safeAssetPath), - assetType: inferEncodableAssetType(safeAssetPath), - tokenHex: job.tokenHex, - }); - } - await receiptGrant(args.grant); return { materializedAssetPaths }; } finally { diff --git a/src/nativeHardening.test.ts b/src/nativeHardening.test.ts index 0dd9ddb..7a92dc3 100644 --- a/src/nativeHardening.test.ts +++ b/src/nativeHardening.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; const repoRoot = path.resolve(import.meta.dir, '..'); @@ -8,6 +8,80 @@ function readNativeFile(...segments: string[]): string { return readFileSync(path.join(repoRoot, ...segments), 'utf8'); } +function extractExportedFunction(source: string, name: string): string { + const start = source.indexOf(`XG_EXPORT int ${name}`); + if (start < 0) { + throw new Error(`Missing native export ${name}`); + } + const next = source.indexOf('\nXG_EXPORT int ', start + 1); + return source.slice(start, next < 0 ? undefined : next); +} + +function readNullTerminatedString(bytes: Buffer, offset: number): string { + const end = bytes.indexOf(0, offset); + return bytes.toString('utf8', offset, end < 0 ? bytes.length : end); +} + +function readElf64DynamicExports(bytes: Buffer): Set { + if (bytes.toString('ascii', 0, 4) !== '\u007fELF') { + throw new Error('Expected an ELF shared object'); + } + if (bytes[4] !== 2 || bytes[5] !== 1) { + throw new Error('Expected a little-endian ELF64 shared object'); + } + + const sectionHeaderOffset = Number(bytes.readBigUInt64LE(40)); + const sectionHeaderEntrySize = bytes.readUInt16LE(58); + const sectionHeaderCount = bytes.readUInt16LE(60); + const sectionNameTableIndex = bytes.readUInt16LE(62); + const sectionHeaders = Array.from({ length: sectionHeaderCount }, (_, index) => { + const offset = sectionHeaderOffset + index * sectionHeaderEntrySize; + return { + nameOffset: bytes.readUInt32LE(offset), + type: bytes.readUInt32LE(offset + 4), + offset: Number(bytes.readBigUInt64LE(offset + 24)), + size: Number(bytes.readBigUInt64LE(offset + 32)), + link: bytes.readUInt32LE(offset + 40), + entrySize: Number(bytes.readBigUInt64LE(offset + 56)), + }; + }); + const sectionNames = sectionHeaders[sectionNameTableIndex]; + if (!sectionNames) { + throw new Error('Missing ELF section name table'); + } + const nameOf = (nameOffset: number) => + readNullTerminatedString(bytes, sectionNames.offset + nameOffset); + const dynsymIndex = sectionHeaders.findIndex( + (section) => section.type === 11 && nameOf(section.nameOffset) === '.dynsym' + ); + if (dynsymIndex < 0) { + throw new Error('Missing .dynsym section'); + } + const dynsym = sectionHeaders[dynsymIndex]; + if (!dynsym) { + throw new Error('Missing .dynsym section'); + } + const dynstr = sectionHeaders[dynsym.link]; + if (!dynstr) { + throw new Error('Missing .dynstr section'); + } + const exports = new Set(); + const symbolCount = Math.floor(dynsym.size / dynsym.entrySize); + for (let index = 0; index < symbolCount; index++) { + const offset = dynsym.offset + index * dynsym.entrySize; + const nameOffset = bytes.readUInt32LE(offset); + const info = bytes[offset + 4] ?? 0; + const sectionIndex = bytes.readUInt16LE(offset + 6); + const bind = info >> 4; + const isDefined = sectionIndex !== 0; + const isGlobalOrWeak = bind === 1 || bind === 2; + if (nameOffset > 0 && isDefined && isGlobalOrWeak) { + exports.add(readNullTerminatedString(bytes, dynstr.offset + nameOffset)); + } + } + return exports; +} + describe('native guard hardening', () => { it('removes runtime env overrides for guard variants and uses one-time init primitives', () => { const guardSource = readNativeFile('yucp_coupling', 'guard.c'); @@ -15,6 +89,35 @@ describe('native guard hardening', () => { expect(guardSource).not.toContain('YUCP_GUARD_VARIANT_RT'); expect(guardSource).toMatch(/InitOnceExecuteOnce|pthread_once/); }); + + it('bounds seeded watermark framing and permutation arithmetic', () => { + const guardSource = readNativeFile('yucp_coupling', 'guard.c'); + + expect(guardSource).toContain('len != (uint8_t)WM_TOKEN_BITS'); + expect(guardSource).toContain('count > SIZE_MAX / sizeof(size_t)'); + expect(guardSource).toMatch(/pa->i\s*<\s*pb->i/); + }); + + it('preflights image dimensions before decoding v2 image watermarks', () => { + const guardSource = readNativeFile('yucp_coupling', 'guard.c'); + + expect(guardSource).toContain('wm_img_preflight'); + for (const name of ['xg_0122', 'xg_0123']) { + const body = extractExportedFunction(guardSource, name); + expect(body.indexOf('wm_img_preflight')).toBeGreaterThanOrEqual(0); + expect(body.indexOf('wm_img_preflight')).toBeLessThan(body.indexOf('stbi_load_from_memory')); + } + }); + + it('wipes parsed v2 seed material on every native entrypoint', () => { + const guardSource = readNativeFile('yucp_coupling', 'guard.c'); + + for (const name of ['xg_0122', 'xg_0123', 'xg_0124', 'xg_0125']) { + expect(extractExportedFunction(guardSource, name)).toContain( + 'sodium_memzero(seed, sizeof(seed));' + ); + } + }); }); describe('native build hardening', () => { @@ -38,10 +141,51 @@ describe('native build hardening', () => { }); describe('native runtime disclosure hygiene', () => { - it('does not write full asset paths into runtime helper result files', () => { + it('has no rundll32 manifest path that could read tokens from or write paths to disk', () => { const runtimeSource = readNativeFile('yucp_coupling', 'coupling_runtime.c'); + const runtimeExports = readNativeFile('yucp_coupling', 'yucp_coupling.runtime-helper.def'); + + // The legacy rundll32 entry point + on-disk manifest/result machinery was removed; the + // importer invokes the encoders in-process so no tokens or asset paths touch the filesystem. + expect(runtimeSource).not.toContain('EntryPoint'); + expect(runtimeSource).not.toContain('yw_run_manifest'); + expect(runtimeSource).not.toContain('RESULT|'); + expect(runtimeSource).not.toContain('FILE|'); + expect(runtimeExports).not.toMatch(/\bEntryPointW?\b/); + expect(runtimeExports).not.toContain('yw_run_manifest'); + }); +}); + +describe('native packaged artifacts', () => { + it('commits Linux artifacts with seeded v2 dynamic exports', () => { + const serverLibraryPath = path.join( + repoRoot, + 'yucp_coupling', + 'out', + 'linux-x64', + 'Release', + 'yucp_coupling.so' + ); + expect(existsSync(serverLibraryPath)).toBe(true); + const serverExports = readElf64DynamicExports(readFileSync(serverLibraryPath)); + + for (const symbol of ['xg_0122', 'xg_0123', 'xg_0124', 'xg_0125']) { + expect(serverExports.has(symbol)).toBe(true); + } - expect(runtimeSource).not.toContain('"%s|%d|%s\\n", entries[i].path_utf8'); - expect(runtimeSource).toContain('"entry[%zu]|%d|%s\\n"'); + const helperLibraryPath = path.join( + repoRoot, + 'yucp_coupling', + 'out', + 'linux-x64', + 'Release', + 'runtime-helper', + 'yucp_coupling.so' + ); + expect(existsSync(helperLibraryPath)).toBe(true); + const helperExports = readElf64DynamicExports(readFileSync(helperLibraryPath)); + for (const symbol of ['xg_0115', 'xg_0120', 'xg_0122', 'xg_0124']) { + expect(helperExports.has(symbol)).toBe(true); + } }); }); diff --git a/src/roundtrip.test.ts b/src/roundtrip.test.ts index 5524465..1e25a77 100644 --- a/src/roundtrip.test.ts +++ b/src/roundtrip.test.ts @@ -2,12 +2,58 @@ import { afterAll, describe, expect, it } from 'bun:test'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { deflateSync } from 'node:zlib'; const SERVICE_SECRET = 'roundtrip-secret'; const SERVICE_PORT = 8791; -const PNG_TOKEN_HEX = '0123456789abcdef0123456789abcdef'; -const PNG_FIXTURE_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2p7xQAAAAASUVORK5CYII='; +// v2 image mark: 64-bit (16 hex) token, embedded across ≥288 keyed 8x8 DCT blocks, so the fixture +// must be a real-sized image (256x256 = 1024 blocks), not the old 1x1 pixel. Flat mid-gray keeps the +// QIM out of clipping range so the embed self-verifies deterministically. +const PNG_TOKEN_HEX = '0123456789abcdef'; +const PNG_SEED_HEX = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; +const PNG_FIXTURE_SIZE = 256; + +function crc32(buf: Buffer): number { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c ^= buf[i] ?? 0; + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + return ~c >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const t = Buffer.from(type, 'ascii'); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0); + return Buffer.concat([len, t, data, crc]); +} + +// Minimal 8-bit RGBA PNG writer — avoids a fragile hardcoded base64 blob. Flat mid-gray. +function makeGrayPng(size: number, gray: number): Buffer { + const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + const row = Buffer.alloc(1 + size * 4); // leading filter byte = 0 + for (let x = 0; x < size; x++) { + row[1 + x * 4] = gray; + row[2 + x * 4] = gray; + row[3 + x * 4] = gray; + row[4 + x * 4] = 255; + } + const raw = Buffer.concat(Array.from({ length: size }, () => Buffer.from(row))); + return Buffer.concat([ + sig, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} process.env.COUPLING_SERVICE_SECRET = SERVICE_SECRET; process.env.COUPLING_SERVICE_PORT = String(SERVICE_PORT); @@ -28,6 +74,58 @@ function createMinimalFbx7500Binary(): Uint8Array { return bytes; } +function createFbxArrayProperty(values: number[]): Buffer { + const raw = Buffer.alloc(values.length * 8); + for (let i = 0; i < values.length; i += 1) { + raw.writeDoubleLE(values[i] ?? 0, i * 8); + } + const property = Buffer.alloc(13); + property.write('d', 0, 'ascii'); + property.writeUInt32LE(values.length, 1); + property.writeUInt32LE(0, 5); + property.writeUInt32LE(raw.byteLength, 9); + return Buffer.concat([property, raw]); +} + +function createFbx7500Node(name: string, properties: Buffer[]): Buffer { + const nameBytes = Buffer.from(name, 'ascii'); + const propertyBytes = Buffer.concat(properties); + const headerLength = 25; + const nullRecord = Buffer.alloc(25); + const node = Buffer.alloc(headerLength + nameBytes.length + propertyBytes.length + nullRecord.length); + const endOffset = BigInt(27 + node.byteLength); + node.writeBigUInt64LE(endOffset, 0); + node.writeBigUInt64LE(BigInt(properties.length), 8); + node.writeBigUInt64LE(BigInt(propertyBytes.length), 16); + node[24] = nameBytes.length; + nameBytes.copy(node, headerLength); + propertyBytes.copy(node, headerLength + nameBytes.length); + return node; +} + +function createSeedableFbx7500MeshBinary(vertexCount = 384): Uint8Array { + const header = Buffer.alloc(27); + Buffer.from('Kaydara FBX Binary ', 'ascii').copy(header, 0); + header[20] = 0x00; + header[21] = 0x1a; + header[22] = 0x00; + header.writeUInt32LE(7500, 23); + + const vertices: number[] = []; + for (let i = 0; i < vertexCount; i += 1) { + const angle = i * 0.173; + const radius = 1 + i * 0.002; + vertices.push( + Math.cos(angle) * radius, + Math.sin(angle) * radius, + ((i % 37) - 18) * 0.015 + ); + } + + const node = createFbx7500Node('Vertices', [createFbxArrayProperty(vertices)]); + return Buffer.concat([header, node, Buffer.alloc(25)]); +} + const serviceProcess = Bun.spawn({ cmd: ['bun', 'src/server.ts'], cwd: path.resolve(import.meta.dir, '..'), @@ -35,6 +133,8 @@ const serviceProcess = Bun.spawn({ ...process.env, COUPLING_SERVICE_SECRET: SERVICE_SECRET, COUPLING_SERVICE_PORT: String(SERVICE_PORT), + COUPLING_WM_MASTER_KEY: + 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100', }, stdout: 'pipe', stderr: 'pipe', @@ -65,12 +165,13 @@ describe('coupling PNG roundtrip', () => { const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-roundtrip-')); try { const assetPath = path.join(workspaceDir, 'artifact.png'); - await writeFile(assetPath, Buffer.from(PNG_FIXTURE_BASE64, 'base64')); + await writeFile(assetPath, makeGrayPng(PNG_FIXTURE_SIZE, 0x80)); encodeCouplingAsset({ filePath: assetPath, assetType: 'png', tokenHex: PNG_TOKEN_HEX, + seedHex: PNG_SEED_HEX, }); const encodedBytes = await readFile(assetPath); @@ -85,6 +186,7 @@ describe('coupling PNG roundtrip', () => { { assetPath: 'Assets/Test/artifact.png', assetType: 'png', + seedHex: PNG_SEED_HEX, contentBase64: Buffer.from(encodedBytes).toString('base64'), }, ], @@ -107,7 +209,7 @@ describe('coupling PNG roundtrip', () => { expect(payload.results[0]).toMatchObject({ assetPath: 'Assets/Test/artifact.png', assetType: 'png', - decoderKind: 'coupling-png-ffi', + decoderKind: 'coupling-png-v2-ffi', tokenHex: PNG_TOKEN_HEX, tokenLength: PNG_TOKEN_HEX.length, }); @@ -125,6 +227,7 @@ describe('coupling PNG roundtrip', () => { { assetPath: 'Assets/Test/artifact.png', assetType: 'png', + seedHex: PNG_SEED_HEX, contentBase64: Buffer.from(encodedBytes).toString('base64'), }, ], @@ -154,53 +257,256 @@ describe('coupling PNG roundtrip', () => { }); }); +describe('per-job seed end to end', () => { + it('derives a seed from the service and round-trips an image placed with it', async () => { + await waitForService(); + + // 1) Server derives the per-(asset, buyer) seed from its master key. + const deriveResponse = await fetch( + `http://127.0.0.1:${SERVICE_PORT}/v1/coupling/internal/derive-seeds`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${SERVICE_SECRET}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenseSubject: 'buyer-xyz', + assetPaths: ['Assets/Test/seeded.png'], + }), + } + ); + expect(deriveResponse.ok).toBe(true); + const derivePayload = (await deriveResponse.json()) as { + seeds: Array<{ assetPath: string; seedHex: string }>; + }; + const seedHex = derivePayload.seeds[0]?.seedHex; + expect(seedHex).toMatch(/^[0-9a-f]{64}$/); + if (!seedHex) throw new Error('service returned no seed'); + + // 2) Embed with the derived seed. + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-seed-')); + try { + const assetPath = path.join(workspaceDir, 'seeded.png'); + await writeFile(assetPath, makeGrayPng(PNG_FIXTURE_SIZE, 0x80)); + encodeCouplingAsset({ filePath: assetPath, assetType: 'png', tokenHex: PNG_TOKEN_HEX, seedHex }); + + // 3) Decode through the service contract using the same seed → token recovered. + const encodedBytes = await readFile(assetPath); + const response = await fetch(`http://127.0.0.1:${SERVICE_PORT}/v1/coupling/scan`, { + method: 'POST', + headers: { Authorization: `Bearer ${SERVICE_SECRET}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assets: [ + { + assetPath: 'Assets/Test/seeded.png', + assetType: 'png', + seedHex, + contentBase64: Buffer.from(encodedBytes).toString('base64'), + }, + ], + }), + }); + const payload = (await response.json()) as { + results: Array<{ tokenHex: string; decoderKind: string }>; + }; + expect(payload.results[0]).toMatchObject({ + decoderKind: 'coupling-png-v2-ffi', + tokenHex: PNG_TOKEN_HEX, + }); + + // 4) A different buyer's seed must NOT decode the same asset. + const otherDerive = await fetch( + `http://127.0.0.1:${SERVICE_PORT}/v1/coupling/internal/derive-seeds`, + { + method: 'POST', + headers: { Authorization: `Bearer ${SERVICE_SECRET}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ licenseSubject: 'buyer-other', assetPaths: ['Assets/Test/seeded.png'] }), + } + ); + const otherPayload = (await otherDerive.json()) as { seeds: Array<{ seedHex: string }> }; + const otherSeed = otherPayload.seeds[0]?.seedHex ?? ''; + expect(otherSeed).toMatch(/^[0-9a-f]{64}$/); + expect(otherSeed).not.toBe(seedHex); + const missResponse = await fetch(`http://127.0.0.1:${SERVICE_PORT}/v1/coupling/scan`, { + method: 'POST', + headers: { Authorization: `Bearer ${SERVICE_SECRET}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assets: [ + { + assetPath: 'Assets/Test/seeded.png', + assetType: 'png', + seedHex: otherSeed, + contentBase64: Buffer.from(encodedBytes).toString('base64'), + }, + ], + }), + }); + const missPayload = (await missResponse.json()) as { + results: Array<{ tokenHex: string }>; + errors: Array; + }; + // Wrong seed → no v2 hit (legacy fallback misses too) → reported as an error, not a match. + expect(missPayload.results).toHaveLength(0); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); +}); + +describe('seed derivation route validation', () => { + it('returns 400 for malformed JSON bodies', async () => { + await waitForService(); + + const response = await fetch( + `http://127.0.0.1:${SERVICE_PORT}/v1/coupling/internal/derive-seeds`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${SERVICE_SECRET}`, + 'Content-Type': 'application/json', + }, + body: '{', + } + ); + const payload = (await response.json()) as { error?: { code?: string } }; + + expect(response.status).toBe(400); + expect(payload.error?.code).toBe('bad_request'); + }); + + it('normalizes safe asset paths before deriving and returning seeds', async () => { + await waitForService(); + + const response = await fetch( + `http://127.0.0.1:${SERVICE_PORT}/v1/coupling/internal/derive-seeds`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${SERVICE_SECRET}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + licenseSubject: 'buyer-xyz', + assetPaths: ['\\Assets\\Test\\seeded.png'], + }), + } + ); + const payload = (await response.json()) as { + seeds: Array<{ assetPath: string; seedHex: string }>; + }; + + expect(response.ok).toBe(true); + expect(payload.seeds[0]?.assetPath).toBe('Assets/Test/seeded.png'); + expect(payload.seeds[0]?.seedHex).toMatch(/^[0-9a-f]{64}$/); + }); +}); + describe('coupling FBX 7500 support', () => { - it('parses FBX 7500 node headers before failing on missing carriers', async () => { + it('treats a vertex-less FBX 7500 binary as a skip and never blocks the import', async () => { const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-fbx7500-')); try { const assetPath = path.join(workspaceDir, 'artifact-7500.fbx'); await writeFile(assetPath, createMinimalFbx7500Binary()); - let thrown: unknown; - try { + // The v2 mesh encoder parses the 7500 header, finds no usable vertices, and SKIPS (rc 1) — + // encodeCouplingAsset must not throw (skip is never an error and never blocks an install). + expect(() => encodeCouplingAsset({ filePath: assetPath, assetType: 'fbx', - tokenHex: '01234567', - }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(NativeCouplingError); - if (!(thrown instanceof NativeCouplingError)) { - throw thrown ?? new Error('Expected a NativeCouplingError'); - } - expect(thrown.code).toBe(-33); + tokenHex: '0123456789abcdef', + seedHex: PNG_SEED_HEX, + }) + ).not.toThrow(); + // A mesh with no embedded mark misses cleanly (v2 decode then legacy fallback both miss). let decodeThrown: unknown; try { - decodeCouplingAsset({ - filePath: assetPath, - assetType: 'fbx', - expectedTokenLength: 32, - }); + decodeCouplingAsset({ filePath: assetPath, assetType: 'fbx' }); } catch (error) { decodeThrown = error; } expect(decodeThrown).toBeInstanceOf(NativeCouplingError); - if (!(decodeThrown instanceof NativeCouplingError)) { - throw decodeThrown ?? new Error('Expected a NativeCouplingError'); - } - expect(decodeThrown.code).toBe(-43); - expect(decodeThrown.message).toBe( - 'native FBX decoder did not find all three binary FBX carrier lanes' - ); } finally { await rm(workspaceDir, { recursive: true, force: true }); } }); + + it('decodes a seeded v2 FBX without requiring the legacy token length default', async () => { + await waitForService(); + + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'coupling-seeded-fbx-')); + try { + const assetPath = path.join(workspaceDir, 'seeded.fbx'); + await writeFile(assetPath, createSeedableFbx7500MeshBinary()); + + encodeCouplingAsset({ + filePath: assetPath, + assetType: 'fbx', + tokenHex: PNG_TOKEN_HEX, + seedHex: PNG_SEED_HEX, + }); + const decoded = decodeCouplingAsset({ + filePath: assetPath, + assetType: 'fbx', + seedHex: PNG_SEED_HEX, + }); + expect(decoded).toMatchObject({ + decoderKind: 'coupling-fbx-v2-ffi', + tokenHex: PNG_TOKEN_HEX, + tokenLength: PNG_TOKEN_HEX.length, + }); + + const encodedBytes = await readFile(assetPath); + const response = await fetch(`http://127.0.0.1:${SERVICE_PORT}/v1/coupling/scan`, { + method: 'POST', + headers: { Authorization: `Bearer ${SERVICE_SECRET}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + assets: [ + { + assetPath: 'Assets/Test/seeded.fbx', + assetType: 'fbx', + seedHex: PNG_SEED_HEX, + contentBase64: Buffer.from(encodedBytes).toString('base64'), + }, + ], + }), + }); + const payload = (await response.json()) as { + status: string; + results: Array<{ decoderKind: string; tokenHex: string; tokenLength: number }>; + errors: Array<{ code: string }>; + }; + + expect(payload.status).toBe('ok'); + expect(payload.errors).toHaveLength(0); + expect(payload.results[0]).toMatchObject({ + decoderKind: 'coupling-fbx-v2-ffi', + tokenHex: PNG_TOKEN_HEX, + tokenLength: PNG_TOKEN_HEX.length, + }); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); +}); + +describe('attestation coupling proof route', () => { + it('routes public coupling proof submissions to the sealed proof handler', async () => { + await waitForService(); + + const response = await fetch(`http://127.0.0.1:${SERVICE_PORT}/v1/attestation/coupling-proof`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload).toEqual({ error: 'sealed channel fields are required' }); + }); }); describe('runtime artifact manifest', () => { diff --git a/src/server.ts b/src/server.ts index 12da509..e12f86e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,14 @@ import { timingSafeEqual } from 'node:crypto'; import { mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; +import { + handleAttestationChallenge, + handleAttestationSubmit, + handleCouplingProof, + handlePaymentAnchor, +} from './attestation'; import { config } from './config'; +import { COUPLING_SEED_EPOCH, deriveCouplingSeedHex, isCouplingSeedConfigured } from './couplingSeed'; import { NativeCouplingError, decodeCouplingAsset, @@ -37,6 +44,7 @@ type CouplingScanManifestAsset = { expectedTokenLength?: unknown; tokenLength?: unknown; contentBase64?: unknown; + seedHex?: unknown; }; type CouplingScanTask = { @@ -44,6 +52,7 @@ type CouplingScanTask = { assetPath: string; assetType: CouplingAssetType; expectedTokenLength?: number; + seedHex?: string; filePath: string; }; @@ -53,6 +62,7 @@ type CouplingScanResult = { decoderKind: string; tokenHex: string; tokenLength: number; + wmVersion: number; }; type CouplingScanError = { @@ -73,6 +83,7 @@ type CouplingForensicScoreResult = { preclassification: ForensicPreclassification; tokenHex?: string; tokenLength?: number; + wmVersion?: number; nativeCode?: number; decodeError?: string; }; @@ -180,6 +191,7 @@ function decodeScanTask(task: CouplingScanTask): CouplingScanResult { ...(task.expectedTokenLength === undefined ? {} : { expectedTokenLength: task.expectedTokenLength }), + ...(task.seedHex === undefined ? {} : { seedHex: task.seedHex }), }); if (task.expectedTokenLength !== undefined && decoded.tokenLength !== task.expectedTokenLength) { @@ -194,6 +206,7 @@ function decodeScanTask(task: CouplingScanTask): CouplingScanResult { decoderKind: decoded.decoderKind, tokenHex: decoded.tokenHex, tokenLength: decoded.tokenLength, + wmVersion: decoded.wmVersion, }; } @@ -313,6 +326,22 @@ function hasValidSecretHeader(request: Request): boolean { return timingSafeEqual(Buffer.from(provided), Buffer.from(actual)); } +/** + * Constant-time check of the Convex relay secret (CONVEX_API_SECRET) on the inbound Authorization + * header. Used by the server-to-server attestation routes. Constant-time so the shared secret is not + * recoverable through response-timing analysis. + */ +function hasValidConvexApiSecret(request: Request): boolean { + const secret = process.env.CONVEX_API_SECRET?.trim(); + if (!secret) return false; + const provided = request.headers.get('Authorization')?.replace(/^Bearer\s+/i, '').trim(); + if (!provided) return false; + const a = toSecretBytes(provided); + const b = toSecretBytes(secret); + if (a.byteLength !== b.byteLength) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + function assertScanAuthorized(request: Request): void { if (!hasValidSecretHeader(request)) { throw new HttpError( @@ -409,10 +438,11 @@ function normalizeAssetType( function normalizeExpectedTokenLength( rawLength: unknown, - assetType: CouplingAssetType + assetType: CouplingAssetType, + hasSeed = false ): number | undefined { if (rawLength === undefined || rawLength === null || rawLength === '') { - return assetType === 'fbx' ? 32 : undefined; + return assetType === 'fbx' && !hasSeed ? 32 : undefined; } const value = Number(rawLength); if (!Number.isInteger(value) || value < 8 || value > 64 || value % 2 !== 0) { @@ -425,6 +455,20 @@ function normalizeExpectedTokenLength( return value; } +function normalizeOptionalSeedHex(rawSeed: unknown, assetPath: string): string | undefined { + if (rawSeed === undefined || rawSeed === null || rawSeed === '') { + return undefined; + } + if (typeof rawSeed !== 'string') { + throw new HttpError(400, 'invalid_seed', `seedHex for "${assetPath}" must be a string`); + } + const normalized = rawSeed.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(normalized)) { + throw new HttpError(400, 'invalid_seed', `seedHex for "${assetPath}" must be 64 hex characters`); + } + return normalized; +} + function getSafeExtension(assetPath: string, uploadName: string, assetType: CouplingAssetType): string { const candidates = [assetPath, uploadName]; for (const candidate of candidates) { @@ -559,9 +603,11 @@ async function buildScanTasks( } const assetType = normalizeAssetType(asset.assetType, assetPath, upload.name); + const seedHex = normalizeOptionalSeedHex(asset.seedHex, assetPath); const expectedTokenLength = normalizeExpectedTokenLength( asset.expectedTokenLength ?? asset.tokenLength, - assetType + assetType, + seedHex !== undefined ); const filePath = buildStoredAssetPath(requestDir, index, assetPath, upload.name, assetType); await Bun.write(filePath, upload); @@ -571,6 +617,7 @@ async function buildScanTasks( assetPath, assetType, ...(expectedTokenLength === undefined ? {} : { expectedTokenLength }), + ...(seedHex === undefined ? {} : { seedHex }), filePath, }); } @@ -651,9 +698,11 @@ async function buildJsonScanTasks( const uploadName = typeof asset.fieldName === 'string' ? asset.fieldName : assetPath; const assetType = normalizeAssetType(asset.assetType, assetPath, uploadName); + const seedHex = normalizeOptionalSeedHex(asset.seedHex, assetPath); const expectedTokenLength = normalizeExpectedTokenLength( asset.expectedTokenLength ?? asset.tokenLength, - assetType + assetType, + seedHex !== undefined ); const filePath = buildStoredAssetPath(requestDir, index, assetPath, uploadName, assetType); await Bun.write(filePath, bytes); @@ -662,6 +711,7 @@ async function buildJsonScanTasks( assetPath, assetType, ...(expectedTokenLength === undefined ? {} : { expectedTokenLength }), + ...(seedHex === undefined ? {} : { seedHex }), filePath, }); } @@ -768,6 +818,82 @@ async function handleScan(request: Request): Promise { } } +async function handleDeriveSeeds(request: Request): Promise { + assertScanAuthorized(request); + if (!isCouplingSeedConfigured()) { + throw new HttpError( + 503, + 'seed_master_unconfigured', + 'COUPLING_WM_MASTER_KEY is not configured on the coupling service' + ); + } + const contentType = request.headers.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('application/json')) { + throw new HttpError( + 415, + 'unsupported_media_type', + 'POST /v1/coupling/internal/derive-seeds requires application/json' + ); + } + + let body: { + licenseSubject?: unknown; + epoch?: unknown; + assetPaths?: unknown; + }; + try { + body = (await request.json()) as typeof body; + } catch { + throw new HttpError(400, 'bad_request', 'Invalid JSON body'); + } + const licenseSubject = + typeof body.licenseSubject === 'string' ? body.licenseSubject.trim() : ''; + if (!licenseSubject) { + throw new HttpError(400, 'missing_license_subject', 'licenseSubject is required'); + } + if (!Array.isArray(body.assetPaths) || body.assetPaths.length === 0) { + throw new HttpError(400, 'missing_asset_paths', 'assetPaths must be a non-empty array'); + } + if (body.assetPaths.length > config.maxAssetsPerRequest) { + throw new HttpError( + 413, + 'too_many_assets', + `A derive-seeds request can include at most ${config.maxAssetsPerRequest} assetPaths` + ); + } + const epoch = + typeof body.epoch === 'number' && Number.isInteger(body.epoch) + ? body.epoch + : COUPLING_SEED_EPOCH; + + const seeds: Array<{ assetPath: string; seedHex: string }> = []; + const seenAssetPaths = new Set(); + for (const raw of body.assetPaths) { + if (typeof raw !== 'string' || !raw.trim()) { + throw new HttpError(400, 'invalid_asset_path', 'each assetPath must be a non-empty string'); + } + const assetPath = normalizeRelativeAssetPath(raw); + if (!isSafeRelativeAssetPath(assetPath)) { + throw new HttpError( + 400, + 'invalid_asset_path', + `assetPath must be a safe relative path, received "${raw}"` + ); + } + if (seenAssetPaths.has(assetPath)) { + throw new HttpError( + 400, + 'duplicate_asset_path', + `assetPath "${assetPath}" appears more than once in the request` + ); + } + seenAssetPaths.add(assetPath); + seeds.push({ assetPath, seedHex: await deriveCouplingSeedHex(assetPath, licenseSubject, epoch) }); + } + + return jsonResponse(200, { success: true, epoch, seeds }); +} + async function handleForensicScore(request: Request): Promise { assertScanAuthorized(request); assertNativeRuntimeAvailable(); @@ -986,12 +1112,66 @@ async function routeRequest(request: Request): Promise { if (request.method === 'POST' && url.pathname === '/v1/coupling/forensic-score') { return await handleForensicScore(request); } + if (request.method === 'POST' && url.pathname === '/v1/coupling/internal/derive-seeds') { + return await handleDeriveSeeds(request); + } if ( request.method === 'POST' && (url.pathname === '/materialize' || url.pathname === '/v1/coupling/materialize') ) { return await handleProtectedMaterialization(request); } + if (request.method === 'POST' && url.pathname === '/v1/attestation/challenge') { + let body: { correlationId?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return jsonResponse(400, { error: { code: 'bad_request', message: 'Invalid JSON body' } }); + } + const result = await handleAttestationChallenge(body?.correlationId ?? ''); + return jsonResponse(result.status, result.body as Record); + } + if (request.method === 'POST' && url.pathname === '/v1/attestation/submit') { + let body: { ephemeralPublicSpkiB64?: string; ivB64?: string; ciphertextB64?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return jsonResponse(400, { error: { code: 'bad_request', message: 'Invalid JSON body' } }); + } + const result = await handleAttestationSubmit({ + ephemeralPublicSpkiB64: body?.ephemeralPublicSpkiB64 ?? '', + ivB64: body?.ivB64 ?? '', + ciphertextB64: body?.ciphertextB64 ?? '', + }); + return jsonResponse(result.status, result.body as Record); + } + if (request.method === 'POST' && url.pathname === '/v1/attestation/coupling-proof') { + let body: { ephemeralPublicSpkiB64?: string; ivB64?: string; ciphertextB64?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return jsonResponse(400, { error: { code: 'bad_request', message: 'Invalid JSON body' } }); + } + const result = await handleCouplingProof({ + ephemeralPublicSpkiB64: body?.ephemeralPublicSpkiB64 ?? '', + ivB64: body?.ivB64 ?? '', + ciphertextB64: body?.ciphertextB64 ?? '', + }); + return jsonResponse(result.status, result.body as Record); + } + if (request.method === 'POST' && url.pathname === '/v1/attestation/payment-anchor') { + if (!hasValidConvexApiSecret(request)) { + return jsonResponse(401, { error: { code: 'unauthorized', message: 'Unauthorized' } }); + } + let body: { licenseSubject?: string; paymentFingerprint?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return jsonResponse(400, { error: { code: 'bad_request', message: 'Invalid JSON body' } }); + } + const result = await handlePaymentAnchor(body?.licenseSubject ?? '', body?.paymentFingerprint ?? ''); + return jsonResponse(result.status, result.body as Record); + } if (request.method === 'GET' && url.pathname === '/v1/licenses/coupling-runtime') { return await handleRuntimeArtifactDownload(request, 'coupling-runtime'); } diff --git a/yucp_coupling/coupling_runtime.c b/yucp_coupling/coupling_runtime.c index 6f495f1..144bc7f 100644 --- a/yucp_coupling/coupling_runtime.c +++ b/yucp_coupling/coupling_runtime.c @@ -1,16 +1,21 @@ #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #include -#include #endif #include -#include #include #include +/* Translation unit for the coupling watermark runtime. It includes guard.c and, for the client + * (runtime-helper) profile, re-exports the legacy encoders under stable names. The legacy rundll32 + * manifest entry point was removed: the importer invokes the encoders in-process via GetProcAddress + * and never writes tokens to a disk manifest. */ + #if defined(YUCP_RUNTIME_HELPER_EXPORTS) #define xg_0115 yucp_runtime_helper_encode_png #define xg_0120 yucp_runtime_helper_encode_fbx +#define xg_0122 yucp_runtime_helper_encode_seeded_png +#define xg_0124 yucp_runtime_helper_encode_seeded_fbx #define XG_EXPORT #elif defined(_WIN32) && defined(__cplusplus) #define XG_EXPORT extern "C" __declspec(dllexport) @@ -27,6 +32,8 @@ #if defined(YUCP_RUNTIME_HELPER_EXPORTS) #undef xg_0115 #undef xg_0120 +#undef xg_0122 +#undef xg_0124 #if defined(_WIN32) && defined(__cplusplus) extern "C" __declspec(dllexport) #elif defined(_WIN32) @@ -52,336 +59,30 @@ __attribute__((visibility("default"))) int xg_0120(const char* file_path_utf8, const char* token_hex_utf8) { return yucp_runtime_helper_encode_fbx(file_path_utf8, token_hex_utf8); } -#endif - -typedef struct yw_job_entry { - char* path_utf8; - char* token_hex; -} yw_job_entry; - -static int yw_size_add(size_t a, size_t b, size_t* out) { - if (!out) return -1; - if (a > SIZE_MAX - b) return -2; - *out = a + b; - return 0; -} - -static int yw_size_mul(size_t a, size_t b, size_t* out) { - if (!out) return -1; - if (a != 0u && b > SIZE_MAX / a) return -2; - *out = a * b; - return 0; -} - -static char* yw_strdup_range(const char* start, size_t len) { - size_t alloc_len = 0u; - if (yw_size_add(len, 1u, &alloc_len) != 0) return NULL; - char* out = (char*)malloc(alloc_len); - if (!out) return NULL; - if (len > 0u) memcpy(out, start, len); - out[len] = '\0'; - return out; -} - -static char* yw_strdup_text(const char* text) { - if (!text) return NULL; - return yw_strdup_range(text, strlen(text)); -} - -#if defined(_WIN32) -static char* yw_wide_to_utf8_alloc(const wchar_t* value) { - if (!value) return NULL; - int len = WideCharToMultiByte(CP_UTF8, 0, value, -1, NULL, 0, NULL, NULL); - if (len <= 0) return NULL; - char* out = (char*)malloc((size_t)len); - if (!out) return NULL; - if (WideCharToMultiByte(CP_UTF8, 0, value, -1, out, len, NULL, NULL) <= 0) { - free(out); - return NULL; - } - return out; -} -#endif - -static void yw_trim_in_place(char* value) { - if (!value) return; - size_t len = strlen(value); - while (len > 0u && (value[len - 1u] == '\r' || value[len - 1u] == '\n' || value[len - 1u] == ' ' || value[len - 1u] == '\t')) { - value[--len] = '\0'; - } - size_t start = 0u; - while (value[start] == ' ' || value[start] == '\t') start++; - if (start > 0u) memmove(value, value + start, strlen(value + start) + 1u); -} -static int yw_has_extension(const char* path_utf8, const char* ext_utf8) { - if (!path_utf8 || !ext_utf8) return 0; - const char* dot = strrchr(path_utf8, '.'); - if (!dot) return 0; - return _stricmp(dot, ext_utf8) == 0; -} - -static int yw_apply_one(const char* path_utf8, const char* token_hex_utf8) { - if (!path_utf8 || !token_hex_utf8) return -1; - if (yw_has_extension(path_utf8, ".png")) { - return xg_0115(path_utf8, token_hex_utf8); - } - if (yw_has_extension(path_utf8, ".fbx")) { - return xg_0120(path_utf8, token_hex_utf8); - } - return 1; -} - -static void yw_free_entries(yw_job_entry* entries, size_t count) { - if (!entries) return; - for (size_t i = 0; i < count; i++) { - free(entries[i].path_utf8); - free(entries[i].token_hex); - } - free(entries); -} - -static int yw_append_entry(yw_job_entry** entries, size_t* count, size_t* cap, const char* path_utf8, const char* token_hex) { - if (!entries || !count || !cap || !path_utf8 || !token_hex) return -1; - if (*count >= *cap) { - size_t next_cap = 16u; - if (*cap != 0u && yw_size_mul(*cap, 2u, &next_cap) != 0) return -2; - size_t alloc_bytes = 0u; - if (yw_size_mul(next_cap, sizeof(yw_job_entry), &alloc_bytes) != 0) return -2; - yw_job_entry* grown = (yw_job_entry*)realloc(*entries, alloc_bytes); - if (!grown) return -2; - *entries = grown; - *cap = next_cap; - } - - char* path_copy = yw_strdup_text(path_utf8); - char* token_copy = yw_strdup_text(token_hex); - if (!path_copy || !token_copy) { - free(path_copy); - free(token_copy); - return -3; - } - - (*entries)[*count].path_utf8 = path_copy; - (*entries)[*count].token_hex = token_copy; - (*count)++; - return 0; -} - -static int yw_append_text(char** buffer, size_t* len, size_t* cap, const char* text) { - if (!buffer || !len || !cap || !text) return -1; - size_t add = strlen(text); - size_t required = 0u; - if (yw_size_add(*len, add, &required) != 0 || yw_size_add(required, 1u, &required) != 0) return -2; - if (required > *cap) { - size_t next_cap = *cap == 0u ? 512u : *cap; - while (required > next_cap) { - if (yw_size_mul(next_cap, 2u, &next_cap) != 0) return -3; - } - char* grown = (char*)realloc(*buffer, next_cap); - if (!grown) return -3; - *buffer = grown; - *cap = next_cap; - } - memcpy(*buffer + *len, text, add); - *len += add; - (*buffer)[*len] = '\0'; - return 0; -} - -#if defined(_WIN32) -static char* yw_parse_manifest_argument(const wchar_t* command_line) { - if (!command_line) return NULL; - while (*command_line == L' ' || *command_line == L'\t') command_line++; - const wchar_t* start = command_line; - const wchar_t* end = command_line; - if (*command_line == L'\"') { - start = ++command_line; - while (*command_line && *command_line != L'\"') command_line++; - end = command_line; - } else { - while (*command_line && *command_line != L' ' && *command_line != L'\t') command_line++; - end = command_line; - } - if (end <= start) return NULL; - size_t len = (size_t)(end - start); - size_t char_count = 0u; - size_t alloc_bytes = 0u; - if (yw_size_add(len, 1u, &char_count) != 0 || yw_size_mul(char_count, sizeof(wchar_t), &alloc_bytes) != 0) return NULL; - wchar_t* temp = (wchar_t*)calloc(1u, alloc_bytes); - if (!temp) return NULL; - wmemcpy(temp, start, len); - temp[len] = L'\0'; - char* utf8 = yw_wide_to_utf8_alloc(temp); - free(temp); - return utf8; -} +#if defined(_WIN32) && defined(__cplusplus) +extern "C" __declspec(dllexport) +#elif defined(_WIN32) +__declspec(dllexport) +#elif defined(__cplusplus) +extern "C" __attribute__((visibility("default"))) +#else +__attribute__((visibility("default"))) #endif - -static char* yw_parse_manifest_argument_utf8(const char* command_line) { - if (!command_line) return NULL; - while (*command_line == ' ' || *command_line == '\t') command_line++; - const char* start = command_line; - const char* end = command_line; - if (*command_line == '"') { - start = ++command_line; - while (*command_line && *command_line != '"') command_line++; - end = command_line; - } else { - while (*command_line && *command_line != ' ' && *command_line != '\t') command_line++; - end = command_line; - } - if (end <= start) return NULL; - return yw_strdup_range(start, (size_t)(end - start)); -} -static int yw_write_result(const char* result_path_utf8, int rc, int applied, int skipped, int failed, const char* details_utf8) { - if (!result_path_utf8 || !details_utf8) return -1; - char header[256]; - _snprintf_s(header, sizeof(header), _TRUNCATE, "status=%s\ncode=%d\napplied=%d\nskipped=%d\nfailed=%d\n", failed == 0 && rc == 0 ? "ok" : "error", rc, applied, skipped, failed); - size_t header_len = strlen(header); - size_t details_len = strlen(details_utf8); - size_t total = 0u; - if (yw_size_add(header_len, details_len, &total) != 0 || yw_size_add(total, 1u, &total) != 0) return -2; - char* body = (char*)malloc(total); - if (!body) return -2; - body[0] = '\0'; - strcat_s(body, total, header); - strcat_s(body, total, details_utf8); - int wr = write_all_bytes_utf8(result_path_utf8, (const uint8_t*)body, strlen(body)); - free(body); - return wr; -} - -static int yw_run_manifest(const char* manifest_path_utf8) { - if (!manifest_path_utf8) return -1; - - uint8_t* manifest_bytes = NULL; - size_t manifest_len = 0u; - if (read_all_bytes_utf8(manifest_path_utf8, &manifest_bytes, &manifest_len) != 0 || !manifest_bytes) { - return -2; - } - - char* manifest_text = (char*)manifest_bytes; - char* result_path = NULL; - yw_job_entry* entries = NULL; - size_t entry_count = 0u; - size_t entry_cap = 0u; - char* details = NULL; - size_t details_len = 0u; - size_t details_cap = 0u; - int applied = 0; - int skipped = 0; - int failed = 0; - int rc = 0; - - char* cursor = manifest_text; - while (cursor && *cursor) { - char* line = cursor; - char* next = strchr(cursor, '\n'); - if (next) { - *next = '\0'; - cursor = next + 1; - } else { - cursor = NULL; - } - - yw_trim_in_place(line); - if (line[0] == '\0') continue; - - if (strncmp(line, "RESULT|", 7) == 0) { - free(result_path); - result_path = yw_strdup_text(line + 7); - yw_trim_in_place(result_path); - continue; - } - - if (strncmp(line, "FILE|", 5) == 0) { - char* first = line + 5; - char* second = strchr(first, '|'); - if (!second) { - rc = -3; - break; - } - *second = '\0'; - char* token = second + 1; - yw_trim_in_place(first); - yw_trim_in_place(token); - if (first[0] == '\0' || token[0] == '\0') { - rc = -4; - break; - } - if (yw_append_entry(&entries, &entry_count, &entry_cap, first, token) != 0) { - rc = -5; - break; - } - continue; - } - } - - if (rc == 0 && (!result_path || result_path[0] == '\0')) rc = -6; - - for (size_t i = 0; rc == 0 && i < entry_count; i++) { - int entry_rc = yw_apply_one(entries[i].path_utf8, entries[i].token_hex); - char line[1024]; - _snprintf_s(line, sizeof(line), _TRUNCATE, "entry[%zu]|%d|%s\n", i + 1u, entry_rc, entry_rc == 0 ? "applied" : (entry_rc == 1 ? "skipped" : "failed")); - if (yw_append_text(&details, &details_len, &details_cap, line) != 0) { - rc = -7; - break; - } - if (entry_rc == 0) applied++; - else if (entry_rc == 1) skipped++; - else { - failed++; - rc = entry_rc; - } - } - - if (!details) { - details = yw_strdup_text("\n"); - } - - if (result_path && details) { - yw_write_result(result_path, rc, applied, skipped, failed, details); - } - - free(details); - free(result_path); - yw_free_entries(entries, entry_count); - size_t wipe_len = manifest_len; - if (wipe_len < SIZE_MAX) wipe_len++; - sodium_memzero(manifest_bytes, wipe_len); - free(manifest_bytes); - return rc; +int xg_0122(const char* file_path_utf8, const char* token_hex_utf8, const char* seed_hex_utf8) { + return yucp_runtime_helper_encode_seeded_png(file_path_utf8, token_hex_utf8, seed_hex_utf8); } -#if defined(_WIN32) -void CALLBACK EntryPoint(HWND hwnd, HINSTANCE instance, LPSTR commandLine, int showState) { - (void)hwnd; - (void)instance; - (void)showState; - SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); - char* manifest_path_utf8 = yw_parse_manifest_argument_utf8(commandLine); - int rc = yw_run_manifest(manifest_path_utf8); - free(manifest_path_utf8); - ExitProcess((UINT)(rc == 0 ? 0 : 1)); -} - -void CALLBACK EntryPointW(HWND hwnd, HINSTANCE instance, LPWSTR commandLine, int showState) { - (void)hwnd; - (void)instance; - (void)showState; - SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX); - char* manifest_path_utf8 = yw_parse_manifest_argument(commandLine); - int rc = yw_run_manifest(manifest_path_utf8); - free(manifest_path_utf8); - ExitProcess((UINT)(rc == 0 ? 0 : 1)); -} +#if defined(_WIN32) && defined(__cplusplus) +extern "C" __declspec(dllexport) +#elif defined(_WIN32) +__declspec(dllexport) +#elif defined(__cplusplus) +extern "C" __attribute__((visibility("default"))) #else -int main(int argc, char** argv) { - const char* manifest_path_utf8 = argc > 1 ? argv[1] : NULL; - int rc = yw_run_manifest(manifest_path_utf8); - return rc == 0 ? 0 : 1; +__attribute__((visibility("default"))) +#endif +int xg_0124(const char* file_path_utf8, const char* token_hex_utf8, const char* seed_hex_utf8) { + return yucp_runtime_helper_encode_seeded_fbx(file_path_utf8, token_hex_utf8, seed_hex_utf8); } #endif - - diff --git a/yucp_coupling/coupling_runtime.exp b/yucp_coupling/coupling_runtime.exp index 4bd81ad..16ca3e9 100644 Binary files a/yucp_coupling/coupling_runtime.exp and b/yucp_coupling/coupling_runtime.exp differ diff --git a/yucp_coupling/coupling_runtime.lib b/yucp_coupling/coupling_runtime.lib index 0c8800d..72c0998 100644 Binary files a/yucp_coupling/coupling_runtime.lib and b/yucp_coupling/coupling_runtime.lib differ diff --git a/yucp_coupling/guard.c b/yucp_coupling/guard.c index b024362..d3b1609 100644 --- a/yucp_coupling/guard.c +++ b/yucp_coupling/guard.c @@ -29,12 +29,15 @@ #include #include #include +#include #if !defined(_WIN32) #include #endif #include "sodium.h" -#define STBI_ONLY_PNG +/* All raster decoders except HDR/EXR (those load as tone-mapped 8-bit, not round-trippable): + * enables JPEG/BMP/TGA/PSD/GIF/PNM source decode so the image watermark covers every common + * Unity texture format, not only PNG. */ #define STBI_NO_STDIO #define STBI_NO_HDR #define STBI_NO_LINEAR @@ -1261,6 +1264,125 @@ static void salt_for(const char* release_id, uint8_t out32[32]) { crypto_hash_sha256_final(&st, out32); } +/* ============================ Watermark v2 foundation ============================ + * Robust, blind, domain-separated watermarking core shared by the image (DWT-DCT spread + * spectrum) and mesh (vertex-norm histogram QIM) algorithms. The image and mesh embedding + * keys are derived independently (HKDF with distinct info), so recovering one domain's key + * never yields the other's. The placement basis is token-independent, so decode is blind. + */ + +#define WM_VER2 0x02u +#define WM_EPOCH 0x00u +#define WM_TOKEN_BITS 64 +#define WM_TOKEN_HEX 16 +#define WM_HDR_BITS 16 /* VER(8) + LEN(8) */ +#define WM_CRC_BITS 16 +#define WM_MSG_BITS (WM_HDR_BITS + WM_TOKEN_BITS + WM_CRC_BITS) /* 96 */ +#define WM_REPS 3 +#define WM_CODE_BITS (WM_MSG_BITS * WM_REPS) /* 288 */ + +/* Per-job seed: 32 bytes (64 hex), supplied by the server per install. NO embedding key is compiled + * into the client — the seed IS the only placement secret, and it is derived server-side as + * HKDF(master, asset|subject|epoch) so a cracked client exposes only that one install. */ +#define WM_SEED_BYTES 32 +#define WM_SEED_HEX 64 + +typedef enum { WM_DOMAIN_IMAGE = 0, WM_DOMAIN_MESH = 1 } wm_domain; + +static int wm_hex32(const char* hex, uint8_t out[32]) { + for (size_t i = 0; i < 32u; i++) { + int hi = hex_nibble(hex[i * 2u]); + int lo = hex_nibble(hex[i * 2u + 1u]); + if (hi < 0 || lo < 0) return -1; + out[i] = (uint8_t)((hi << 4) | lo); + } + return 0; +} + +/* Token-independent placement basis derived from the per-job seed (so decode re-derives it from the + * same seed). Domain string keeps image and mesh placements separated even under the same seed. */ +static int wm_basis_seed(const uint8_t* seed_ikm, size_t seed_len, wm_domain dom, uint8_t epoch, + uint8_t out_seed32[32]) { + if (!seed_ikm || seed_len == 0u) return -1; + const char* info = (dom == WM_DOMAIN_IMAGE) + ? XG_LIT("yucp.wm.image.embed.v2") + : XG_LIT("yucp.wm.mesh.embed.v2"); + uint8_t infobuf[64]; + size_t il = strlen(info); + if (il + 1u > sizeof(infobuf)) return -2; + memcpy(infobuf, info, il); + infobuf[il] = epoch; + hkdf_sha256(seed_ikm, seed_len, NULL, 0, infobuf, il + 1u, out_seed32, 32u); + return 0; +} + +/* CRC-16/CCITT-FALSE over a bit array (one bit per byte). */ +static uint16_t wm_crc16_bits(const uint8_t* bits, size_t nbits) { + uint16_t crc = 0xFFFFu; + for (size_t i = 0; i < nbits; i++) { + uint16_t inbit = (uint16_t)(bits[i] & 1u); + uint16_t top = (uint16_t)((crc >> 15) & 1u); + crc = (uint16_t)(crc << 1); + if (top ^ inbit) crc ^= 0x1021u; + } + return crc; +} + +/* Frame VER|LEN|TOKEN|CRC16 into WM_MSG_BITS (one bit per byte). token_hex must be WM_TOKEN_HEX. */ +static int wm_frame(const char* token_hex, uint8_t out_bits[WM_MSG_BITS]) { + if (!token_hex || strlen(token_hex) != (size_t)WM_TOKEN_HEX) return -1; + size_t bi = 0; + uint8_t ver = WM_VER2; + uint8_t len = (uint8_t)WM_TOKEN_BITS; + for (int i = 7; i >= 0; i--) out_bits[bi++] = (uint8_t)((ver >> i) & 1u); + for (int i = 7; i >= 0; i--) out_bits[bi++] = (uint8_t)((len >> i) & 1u); + for (size_t k = 0; k < (size_t)WM_TOKEN_BITS; k++) out_bits[bi++] = xw_hex_bit(token_hex, k); + uint16_t crc = wm_crc16_bits(out_bits, bi); + for (int i = 15; i >= 0; i--) out_bits[bi++] = (uint8_t)((crc >> i) & 1u); + return 0; +} + +/* Validate recovered WM_MSG_BITS (VER + CRC) and emit token hex. Returns 0 on success. */ +static int wm_unframe(const uint8_t in_bits[WM_MSG_BITS], char out_hex[WM_TOKEN_HEX + 1]) { + uint8_t ver = 0; + for (size_t i = 0; i < 8u; i++) ver = (uint8_t)((ver << 1) | (in_bits[i] & 1u)); + if (ver != WM_VER2) return -1; + uint8_t len = 0; + for (size_t i = 0; i < 8u; i++) len = (uint8_t)((len << 1) | (in_bits[8u + i] & 1u)); + if (len != (uint8_t)WM_TOKEN_BITS) return -2; + uint16_t crc_calc = wm_crc16_bits(in_bits, (size_t)WM_HDR_BITS + WM_TOKEN_BITS); + uint16_t crc_in = 0; + for (size_t i = 0; i < 16u; i++) + crc_in = (uint16_t)((crc_in << 1) | (in_bits[(size_t)WM_HDR_BITS + WM_TOKEN_BITS + i] & 1u)); + if (crc_calc != crc_in) return -3; + static const char hexd[] = "0123456789abcdef"; + for (size_t k = 0; k < (size_t)WM_TOKEN_HEX; k++) { + uint8_t nib = 0; + for (size_t b = 0; b < 4u; b++) + nib = (uint8_t)((nib << 1) | (in_bits[(size_t)WM_HDR_BITS + k * 4u + b] & 1u)); + out_hex[k] = hexd[nib & 0xF]; + } + out_hex[WM_TOKEN_HEX] = '\0'; + return 0; +} + +/* Repetition encode WM_MSG_BITS -> WM_CODE_BITS. */ +static void wm_rep_encode(const uint8_t msg[WM_MSG_BITS], uint8_t code[WM_CODE_BITS]) { + size_t ci = 0; + for (size_t i = 0; i < (size_t)WM_MSG_BITS; i++) + for (int r = 0; r < WM_REPS; r++) code[ci++] = msg[i]; +} + +/* Soft-decision majority decode: per-coded-bit signed votes (>0 => 1) summed per message bit. */ +static void wm_rep_decode(const double votes[WM_CODE_BITS], uint8_t msg[WM_MSG_BITS]) { + size_t ci = 0; + for (size_t i = 0; i < (size_t)WM_MSG_BITS; i++) { + double acc = 0.0; + for (int r = 0; r < WM_REPS; r++) acc += votes[ci++]; + msg[i] = (acc >= 0.0) ? 1u : 0u; + } +} + static void wr_u16_le(uint8_t* p, uint16_t v) { p[0] = (uint8_t)(v & 0xFFu); p[1] = (uint8_t)((v >> 8) & 0xFFu); @@ -2581,6 +2703,338 @@ XG_EXPORT int xg_0117(const char* file_path_utf8, return (wr == 0) ? 0 : -14; } +/* ===================== Watermark v2 image: 8x8 block DCT-domain QIM ===================== + * Robust pixel-domain replacement for the strippable tEXt metadata watermark. Bits are placed + * in a mid-band DCT coefficient of the blue channel, one coded bit per keyed pseudo-random 8x8 + * block. Quantizer step is fixed (decoder re-derives nothing about it) so decode is blind. + */ + +#define WM_PI 3.14159265358979323846 +#define WM_IMG_QSTEP 12.0 +/* Only 288 keyed blocks are ever processed regardless of resolution, so a generous cap is cheap and + * lets 4K textures (4096x4096 RGBA ~= 67 MiB uncompressed) carry the mark instead of skipping. */ +#define WM_IMG_MAX_BYTES (192u * 1024u * 1024u) +#define WM_IMG_MAX_DIM 8192u +#define WM_IMG_MAX_PIXELS (4096u * 4096u) +#define WM_IMG_COEFF_U 2 /* mid-band coefficient row */ +#define WM_IMG_COEFF_V 3 /* mid-band coefficient col */ + +static void wm_dct8_1d(const double in[8], double out[8]) { + for (int k = 0; k < 8; k++) { + double s = 0.0; + for (int n = 0; n < 8; n++) s += in[n] * cos(WM_PI * (2.0 * n + 1.0) * (double)k / 16.0); + double ck = (k == 0) ? 0.3535533905932737622 : 0.5; /* sqrt(1/8), sqrt(2/8) */ + out[k] = ck * s; + } +} + +static void wm_idct8_1d(const double in[8], double out[8]) { + for (int n = 0; n < 8; n++) { + double s = 0.0; + for (int k = 0; k < 8; k++) { + double ck = (k == 0) ? 0.3535533905932737622 : 0.5; + s += ck * in[k] * cos(WM_PI * (2.0 * n + 1.0) * (double)k / 16.0); + } + out[n] = s; + } +} + +static void wm_dct8x8(double blk[8][8]) { + double tmp[8][8], v[8], o[8]; + for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) v[j] = blk[i][j]; wm_dct8_1d(v, o); for (int j = 0; j < 8; j++) tmp[i][j] = o[j]; } + for (int j = 0; j < 8; j++) { for (int i = 0; i < 8; i++) v[i] = tmp[i][j]; wm_dct8_1d(v, o); for (int i = 0; i < 8; i++) blk[i][j] = o[i]; } +} + +static void wm_idct8x8(double blk[8][8]) { + double tmp[8][8], v[8], o[8]; + for (int j = 0; j < 8; j++) { for (int i = 0; i < 8; i++) v[i] = blk[i][j]; wm_idct8_1d(v, o); for (int i = 0; i < 8; i++) tmp[i][j] = o[i]; } + for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) v[j] = tmp[i][j]; wm_idct8_1d(v, o); for (int j = 0; j < 8; j++) blk[i][j] = o[j]; } +} + +/* Keyed full shuffle of [0,count); caller uses the first `want` and frees the buffer. */ +static int wm_perm_take(const uint8_t seed32[32], size_t count, size_t want, size_t** out) { + if (count == 0u || want > count || !out) return -1; + if (count > SIZE_MAX / sizeof(size_t)) return -2; + size_t* p = (size_t*)malloc(count * sizeof(size_t)); + if (!p) return -3; + for (size_t i = 0; i < count; i++) p[i] = i; + uint64_t s0 = xw_seed64(seed32, 0xD1B54A32D192ED03ull); + uint64_t s1 = xw_seed64(seed32, 0x9E3779B97F4A7C15ull); + for (size_t i = count - 1u; i > 0u; i--) { + uint64_t r = xw_rng(&s0, &s1); + size_t j = (size_t)(r % (i + 1u)); + size_t t = p[i]; p[i] = p[j]; p[j] = t; + } + *out = p; + return 0; +} + +static void wm_img_load_block(const uint8_t* px, int w, int bx, int by, double blk[8][8]) { + for (int i = 0; i < 8; i++) + for (int j = 0; j < 8; j++) { + size_t idx = (((size_t)(by * 8 + i) * (size_t)w) + (size_t)(bx * 8 + j)) * 4u + 2u; + blk[i][j] = (double)px[idx]; + } +} + +static void wm_img_store_block(uint8_t* px, int w, int bx, int by, const double blk[8][8]) { + for (int i = 0; i < 8; i++) + for (int j = 0; j < 8; j++) { + double v = blk[i][j]; + if (v < 0.0) v = 0.0; + if (v > 255.0) v = 255.0; + size_t idx = (((size_t)(by * 8 + i) * (size_t)w) + (size_t)(bx * 8 + j)) * 4u + 2u; + px[idx] = (uint8_t)(v + 0.5); + } +} + +/* Returns 0 embedded, 1 skip (too small), negative on error. */ +static int wm_img_embed_px(uint8_t* px, int w, int h, const char* token_hex, double q, + const uint8_t* seed_ikm, size_t seed_len) { + if (w < 8 || h < 8) return 1; + int bxs = w / 8, bys = h / 8; + size_t nb = (size_t)bxs * (size_t)bys; + if (nb < (size_t)WM_CODE_BITS) return 1; + + uint8_t msg[WM_MSG_BITS], code[WM_CODE_BITS]; + if (wm_frame(token_hex, msg) != 0) return -2; + wm_rep_encode(msg, code); + + uint8_t seed[32]; + if (wm_basis_seed(seed_ikm, seed_len, WM_DOMAIN_IMAGE, WM_EPOCH, seed) != 0) return -3; + size_t* perm = NULL; + if (wm_perm_take(seed, nb, (size_t)WM_CODE_BITS, &perm) != 0) { sodium_memzero(seed, sizeof(seed)); return -4; } + + for (size_t ci = 0; ci < (size_t)WM_CODE_BITS; ci++) { + size_t b = perm[ci]; + int bx = (int)(b % (size_t)bxs), by = (int)(b / (size_t)bxs); + double blk[8][8]; + wm_img_load_block(px, w, bx, by, blk); + wm_dct8x8(blk); + blk[WM_IMG_COEFF_U][WM_IMG_COEFF_V] = xw_qe(blk[WM_IMG_COEFF_U][WM_IMG_COEFF_V], q, code[ci]); + wm_idct8x8(blk); + wm_img_store_block(px, w, bx, by, blk); + } + free(perm); + sodium_memzero(seed, sizeof(seed)); + (void)bys; + return 0; +} + +static int wm_img_extract_px(const uint8_t* px, int w, int h, double q, char out_hex[WM_TOKEN_HEX + 1], + const uint8_t* seed_ikm, size_t seed_len) { + if (w < 8 || h < 8) return -1; + int bxs = w / 8, bys = h / 8; + size_t nb = (size_t)bxs * (size_t)bys; + if (nb < (size_t)WM_CODE_BITS) return -2; + + uint8_t seed[32]; + if (wm_basis_seed(seed_ikm, seed_len, WM_DOMAIN_IMAGE, WM_EPOCH, seed) != 0) return -3; + size_t* perm = NULL; + if (wm_perm_take(seed, nb, (size_t)WM_CODE_BITS, &perm) != 0) { sodium_memzero(seed, sizeof(seed)); return -4; } + + double votes[WM_CODE_BITS]; + for (size_t ci = 0; ci < (size_t)WM_CODE_BITS; ci++) { + size_t b = perm[ci]; + int bx = (int)(b % (size_t)bxs), by = (int)(b / (size_t)bxs); + double blk[8][8]; + wm_img_load_block(px, w, bx, by, blk); + wm_dct8x8(blk); + double x = blk[WM_IMG_COEFF_U][WM_IMG_COEFF_V] / q; + long long nr = (long long)llround(x); + double conf = 1.0 - 2.0 * fabs(x - (double)nr); /* 1 at center, 0 at boundary */ + int parity = (int)(nr & 1ll); + votes[ci] = (parity ? 1.0 : -1.0) * conf; + } + free(perm); + sodium_memzero(seed, sizeof(seed)); + (void)bys; + + uint8_t msg[WM_MSG_BITS]; + wm_rep_decode(votes, msg); + return wm_unframe(msg, out_hex); +} + +/* Format is chosen by extension so the file is rewritten in place AS the format Unity keys on + * (a .tga stays a .tga). stb_image_write covers PNG/TGA/BMP/JPG; anything else (PSD/GIF/TIFF/EXR) + * we can sometimes decode but not re-emit losslessly in the same container -> skip, never block. */ +typedef enum { WM_IMGFMT_NONE = 0, WM_IMGFMT_PNG, WM_IMGFMT_TGA, WM_IMGFMT_BMP, WM_IMGFMT_JPG } wm_imgfmt; +#define WM_IMG_JPG_QUALITY 95 + +static wm_imgfmt wm_img_fmt_from_path(const char* path) { + if (!path) return WM_IMGFMT_NONE; + size_t n = strlen(path); + const char* dot = NULL; + for (size_t i = n; i > 0u; i--) { + char ch = path[i - 1u]; + if (ch == '.') { dot = path + (i - 1u); break; } + if (ch == '/' || ch == '\\') break; + } + if (!dot) return WM_IMGFMT_NONE; + if (_stricmp(dot, ".png") == 0) return WM_IMGFMT_PNG; + if (_stricmp(dot, ".tga") == 0) return WM_IMGFMT_TGA; + if (_stricmp(dot, ".bmp") == 0) return WM_IMGFMT_BMP; + if (_stricmp(dot, ".jpg") == 0 || _stricmp(dot, ".jpeg") == 0) return WM_IMGFMT_JPG; + return WM_IMGFMT_NONE; +} + +/* Returns stb truthiness (nonzero = wrote into `out`). */ +static int wm_img_write(wm_imgfmt fmt, xw_o* out, int w, int h, const uint8_t* px) { + switch (fmt) { + case WM_IMGFMT_PNG: return stbi_write_png_to_func(xw_wcb, out, w, h, 4, px, w * 4); + case WM_IMGFMT_TGA: return stbi_write_tga_to_func(xw_wcb, out, w, h, 4, px); + case WM_IMGFMT_BMP: return stbi_write_bmp_to_func(xw_wcb, out, w, h, 4, px); + case WM_IMGFMT_JPG: return stbi_write_jpg_to_func(xw_wcb, out, w, h, 4, px, WM_IMG_JPG_QUALITY); + default: return 0; + } +} + +static int wm_img_preflight(const uint8_t* bytes, size_t len, int* out_w, int* out_h) { + if (!bytes || len < 20u || len > WM_IMG_MAX_BYTES || len > (size_t)INT_MAX || !out_w || !out_h) return -1; + + int w = 0; + int h = 0; + int c = 0; + if (!stbi_info_from_memory((const stbi_uc*)bytes, (int)len, &w, &h, &c)) return -1; + (void)c; + if (w <= 0 || h <= 0) return -1; + + uint64_t uw = (uint64_t)w; + uint64_t uh = (uint64_t)h; + if (uw > (uint64_t)WM_IMG_MAX_DIM || uh > (uint64_t)WM_IMG_MAX_DIM) return -1; + if (uw * uh > (uint64_t)WM_IMG_MAX_PIXELS) return -1; + + *out_w = w; + *out_h = h; + return 0; +} + +/* Image v2 encode. token_hex = WM_TOKEN_HEX, seed_hex = WM_SEED_HEX (per-job). 0 ok, 1 skip, neg err. */ +XG_EXPORT int xg_0122(const char* file_path_utf8, const char* token_hex_utf8, const char* seed_hex_utf8) { + if (sodium_init() < 0) return -1; + if (!file_path_utf8 || !token_hex_utf8 || !seed_hex_utf8) return -2; + if (strlen(token_hex_utf8) != (size_t)WM_TOKEN_HEX || !hex_only(token_hex_utf8)) return -3; + uint8_t seed[WM_SEED_BYTES] = { 0 }; + if (strlen(seed_hex_utf8) != (size_t)WM_SEED_HEX || !hex_only(seed_hex_utf8) || wm_hex32(seed_hex_utf8, seed) != 0) { + sodium_memzero(seed, sizeof(seed)); + return -3; + } + + int rc = 1; + uint8_t* b0 = NULL; + size_t n0 = 0; + uint8_t* px = NULL; + uint8_t* vpx = NULL; + xw_o out = { 0 }; + + wm_imgfmt fmt = wm_img_fmt_from_path(file_path_utf8); + if (fmt == WM_IMGFMT_NONE) goto done; /* unsupported container (PSD/TIFF/EXR/...) -> skip, don't block */ + + /* Per-file conditions that simply mean "can't carry this asset" all SKIP (1), never block the + * import: unreadable/oversized/undecodable bytes, or an embed that doesn't self-verify. Only true + * misconfiguration (sodium/args/token, validated above) returns a hard negative. */ + if (read_all_bytes_utf8(file_path_utf8, &b0, &n0) != 0 || !b0) goto done; + if (n0 < 20u || n0 > WM_IMG_MAX_BYTES) goto done; + + int w = 0, h = 0, c = 0; + if (wm_img_preflight(b0, n0, &w, &h) != 0) goto done; + px = stbi_load_from_memory((const stbi_uc*)b0, (int)n0, &w, &h, &c, 4); + (void)c; + if (!px || w <= 0 || h <= 0) goto done; + + int er = wm_img_embed_px(px, w, h, token_hex_utf8, WM_IMG_QSTEP, seed, sizeof(seed)); + if (er != 0) { rc = (er < 0) ? 1 : er; goto done; } /* uncarryable -> skip */ + + char chk[WM_TOKEN_HEX + 1]; + if (wm_img_extract_px(px, w, h, WM_IMG_QSTEP, chk, seed, sizeof(seed)) != 0 || _stricmp(chk, token_hex_utf8) != 0) { + goto done; /* didn't self-verify -> skip */ + } + + int wr0 = wm_img_write(fmt, &out, w, h, px); + if (!wr0 || out.e || !out.b || out.n == 0u) { + rc = -8; + goto done; + } + + /* Lossy containers (JPG) re-quantize on write, which can erase the mark. Verify against the + * actually-encoded bytes; if it didn't survive, skip (leave the file untouched) rather than + * ship a silently-broken watermark. Lossless containers are bit-exact so this is JPG-only. */ + if (fmt == WM_IMGFMT_JPG) { + int vw = 0, vh = 0, vc = 0; + if (wm_img_preflight(out.b, out.n, &vw, &vh) != 0) goto done; + vpx = stbi_load_from_memory((const stbi_uc*)out.b, (int)out.n, &vw, &vh, &vc, 4); + (void)vc; + char vchk[WM_TOKEN_HEX + 1]; + int ok = (vpx && vw == w && vh == h && + wm_img_extract_px(vpx, vw, vh, WM_IMG_QSTEP, vchk, seed, sizeof(seed)) == 0 && + _stricmp(vchk, token_hex_utf8) == 0); + if (!ok) goto done; /* didn't survive JPEG -> skip */ + } + + int wr = write_all_bytes_utf8(file_path_utf8, out.b, out.n); + rc = (wr == 0) ? 0 : -9; + +done: + if (vpx) stbi_image_free(vpx); + if (px) stbi_image_free(px); + if (b0) { + sodium_memzero(b0, n0 + 1u); + free(b0); + } + if (out.b) { + sodium_memzero(out.b, out.c); + free(out.b); + } + sodium_memzero(seed, sizeof(seed)); + return rc; +} + +/* ============================================================================================ + * DECODERS — server engine only. The client (runtime-helper) build embeds but never decodes, so + * the decode entry points are compiled out entirely: the shipped client binary contains no decode + * logic to lift, and the forensic recovery path lives solely in the closed server build. + * ============================================================================================ */ +#if !defined(YUCP_RUNTIME_HELPER_EXPORTS) + +/* Image v2 decode. seed_hex = WM_SEED_HEX (the job's seed). Writes token hex + NUL. 0 ok, neg miss. */ +XG_EXPORT int xg_0123(const char* file_path_utf8, const char* seed_hex_utf8, char* out_hex_utf8, uint32_t out_cap) { + if (sodium_init() < 0) return -1; + if (!file_path_utf8 || !seed_hex_utf8 || !out_hex_utf8 || out_cap < (uint32_t)(WM_TOKEN_HEX + 1)) return -2; + uint8_t seed[WM_SEED_BYTES] = { 0 }; + if (strlen(seed_hex_utf8) != (size_t)WM_SEED_HEX || !hex_only(seed_hex_utf8) || wm_hex32(seed_hex_utf8, seed) != 0) { + sodium_memzero(seed, sizeof(seed)); + return -2; + } + + uint8_t* b0 = NULL; + size_t n0 = 0; + uint8_t* px = NULL; + int rc = -3; + if (read_all_bytes_utf8(file_path_utf8, &b0, &n0) != 0 || !b0) goto done; + if (n0 < 20u || n0 > WM_IMG_MAX_BYTES) { rc = -4; goto done; } + + int w = 0, h = 0, c = 0; + if (wm_img_preflight(b0, n0, &w, &h) != 0) { rc = -5; goto done; } + px = stbi_load_from_memory((const stbi_uc*)b0, (int)n0, &w, &h, &c, 4); + (void)c; + if (!px || w <= 0 || h <= 0) { rc = -5; goto done; } + + char tok[WM_TOKEN_HEX + 1]; + int er = wm_img_extract_px(px, w, h, WM_IMG_QSTEP, tok, seed, sizeof(seed)); + if (er != 0) { rc = -10; goto done; } /* no valid v2 watermark (CRC/version fail) */ + memcpy(out_hex_utf8, tok, (size_t)WM_TOKEN_HEX + 1u); + rc = 0; + +done: + if (px) stbi_image_free(px); + if (b0) { + sodium_memzero(b0, n0 + 1u); + free(b0); + } + sodium_memzero(seed, sizeof(seed)); + return rc; +} + XG_EXPORT int xg_0118(const char* file_path_utf8, char* out_hex_utf8, uint32_t out_cap) { if (sodium_init() < 0) return -1; if (!file_path_utf8 || !out_hex_utf8 || out_cap < 9u) return -2; @@ -2744,6 +3198,8 @@ XG_EXPORT int xg_0119(const char* file_path_utf8, char* out_hex_utf8, uint32_t o return 0; } +#endif /* !YUCP_RUNTIME_HELPER_EXPORTS — end image/text/legacy decoders */ + typedef struct xw_bp { char t; const uint8_t* rp; @@ -3703,6 +4159,332 @@ static int xw_bin_extract(const char* file_path_utf8, const uint8_t* b0, size_t return rc == 0 ? 0 : -48; } +/* ============== Watermark v2 mesh: vertex-norm distribution histogram QIM ============== + * Blind, universal (every mesh has vertices), robust to vertex reorder + similarity transform. + * Embeds one bit per equal-frequency norm bin by mean-modulation (Cho-Prost-Jung Type I): a fixed + * within-bin power map raises (bit 1) or lowers (bit 0) the bin's mean. Endpoints are fixed points, + * so bins, rho-min/max, and ranks stay reproducible for blind decode. Displacement is clamped for + * invisibility. Binary FBX only; ASCII / too-few-vertices return skip (1). + */ + +#define WM_MESH_MIN_PER_BIN 3u +#define WM_MESH_DISP_FRAC 5.0e-3 + +typedef struct { xw_bp* p; uint32_t i0; } wm_vref; +typedef struct { wm_vref* d; size_t n; size_t c; } wm_vlist; +typedef struct { double k; size_t i; } wm_pair; + +static int wm_vpush(wm_vlist* vl, xw_bp* p, uint32_t i0) { + if (vl->n == vl->c) { + size_t nc = vl->c == 0u ? 4096u : vl->c * 2u; + wm_vref* nd = (wm_vref*)realloc(vl->d, nc * sizeof(wm_vref)); + if (!nd) return -1; + vl->d = nd; vl->c = nc; + } + vl->d[vl->n].p = p; vl->d[vl->n].i0 = i0; vl->n++; + return 0; +} + +static void wm_vlist_free(wm_vlist* vl) { + if (vl->d) { free(vl->d); vl->d = NULL; } + vl->n = 0u; vl->c = 0u; +} + +static double wm_vget(const xw_bp* p, uint32_t idx) { + double v = 0.0; + if (p->t == 'd') { memcpy(&v, p->ad + (size_t)idx * 8u, 8u); } + else { float f = 0.0f; memcpy(&f, p->ad + (size_t)idx * 4u, 4u); v = (double)f; } + return v; +} + +static void wm_vset(xw_bp* p, uint32_t idx, double v) { + if (p->t == 'd') { memcpy(p->ad + (size_t)idx * 8u, &v, 8u); } + else { float f = (float)v; memcpy(p->ad + (size_t)idx * 4u, &f, 4u); } + p->dirty = 1; +} + +static int wm_collect_verts(xw_bn* nd, wm_vlist* vl) { + if (nd->name && strcmp(nd->name, "Vertices") == 0) { + for (size_t i = 0u; i < nd->pn; i++) { + xw_bp* p = &nd->p[i]; + if (!p->arr || !(p->t == 'd' || p->t == 'f')) continue; + if (p->al < 3u || (p->al % 3u) != 0u) continue; + if (xw_bp_load(p) != 0) return -2; + for (uint32_t j = 0u; j + 2u < p->al; j += 3u) { + if (wm_vpush(vl, p, j) != 0) return -3; + } + break; /* one Vertices array per node */ + } + } + for (size_t i = 0u; i < nd->cn; i++) { + int rc = wm_collect_verts(&nd->ch[i], vl); + if (rc != 0) return rc; + } + return 0; +} + +static int wm_pair_cmp(const void* a, const void* b) { + const wm_pair* pa = (const wm_pair*)a; + const wm_pair* pb = (const wm_pair*)b; + if (pa->k < pb->k) return -1; + if (pa->k > pb->k) return 1; + return (pa->i < pb->i) ? -1 : ((pa->i > pb->i) ? 1 : 0); +} + +/* token_hex != NULL => embed (writes modified verts to p->ad, marks dirty); == NULL => decode + * (fills out_hex). Returns 0 ok, 1 skip (uncarryable), negative error; decode returns -10 on miss. */ +static int wm_mesh_run(xw_br* br, const char* token_hex, char out_hex[WM_TOKEN_HEX + 1], + const uint8_t* seed_ikm, size_t seed_len) { + wm_vlist vl = { 0 }; + for (size_t i = 0u; i < br->rn; i++) { + if (wm_collect_verts(&br->r[i], &vl) != 0) { wm_vlist_free(&vl); return -2; } + } + size_t N = vl.n; + /* Adaptive bins/reps so small meshes still carry the 96-bit message (decode recomputes the same + * from N): bins = msg_bits * reps, reps in [1, WM_REPS], each bin gets >= MIN_PER_BIN vertices. */ + if (N < (size_t)WM_MSG_BITS * (size_t)WM_MESH_MIN_PER_BIN) { wm_vlist_free(&vl); return token_hex ? 1 : -10; } + size_t reps = (N / (size_t)WM_MESH_MIN_PER_BIN) / (size_t)WM_MSG_BITS; + if (reps == 0u) { wm_vlist_free(&vl); return token_hex ? 1 : -10; } + if (reps > (size_t)WM_REPS) reps = (size_t)WM_REPS; + size_t B = (size_t)WM_MSG_BITS * reps; + + double* X = (double*)malloc(N * sizeof(double)); + double* Y = (double*)malloc(N * sizeof(double)); + double* Z = (double*)malloc(N * sizeof(double)); + double* R = (double*)malloc(N * sizeof(double)); + wm_pair* pr = (wm_pair*)malloc(N * sizeof(wm_pair)); + size_t* bperm = NULL; + uint8_t seed[32]; + if (!X || !Y || !Z || !R || !pr) { free(X); free(Y); free(Z); free(R); free(pr); wm_vlist_free(&vl); return -3; } + + double cx = 0.0, cy = 0.0, cz = 0.0; + double minx = 0, miny = 0, minz = 0, maxx = 0, maxy = 0, maxz = 0; + for (size_t i = 0u; i < N; i++) { + double x = wm_vget(vl.d[i].p, vl.d[i].i0); + double y = wm_vget(vl.d[i].p, vl.d[i].i0 + 1u); + double z = wm_vget(vl.d[i].p, vl.d[i].i0 + 2u); + X[i] = x; Y[i] = y; Z[i] = z; + cx += x; cy += y; cz += z; + if (i == 0u) { minx = maxx = x; miny = maxy = y; minz = maxz = z; } + else { + if (x < minx) minx = x; if (x > maxx) maxx = x; + if (y < miny) miny = y; if (y > maxy) maxy = y; + if (z < minz) minz = z; if (z > maxz) maxz = z; + } + } + cx /= (double)N; cy /= (double)N; cz /= (double)N; + double ddx = maxx - minx, ddy = maxy - miny, ddz = maxz - minz; + double diag = sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + for (size_t i = 0u; i < N; i++) { + double dx = X[i] - cx, dy = Y[i] - cy, dz = Z[i] - cz; + R[i] = sqrt(dx * dx + dy * dy + dz * dz); + pr[i].k = R[i]; pr[i].i = i; + } + qsort(pr, N, sizeof(wm_pair), wm_pair_cmp); + + if (wm_basis_seed(seed_ikm, seed_len, WM_DOMAIN_MESH, WM_EPOCH, seed) != 0) { free(X); free(Y); free(Z); free(R); free(pr); wm_vlist_free(&vl); return -4; } + if (wm_perm_take(seed, B, B, &bperm) != 0) { sodium_memzero(seed, sizeof(seed)); free(X); free(Y); free(Z); free(R); free(pr); wm_vlist_free(&vl); return -5; } + sodium_memzero(seed, sizeof(seed)); + + int rc = 0; + if (token_hex) { + uint8_t msg[WM_MSG_BITS]; + if (wm_frame(token_hex, msg) != 0) { rc = -6; goto done; } + double eps = (diag > 0.0) ? diag * WM_MESH_DISP_FRAC : 0.0; + for (size_t k = 0u; k < B; k++) { + size_t lo = k * N / B, hi = (k + 1u) * N / B; + if (hi <= lo) continue; + double blo = pr[lo].k, bhi = pr[hi - 1u].k, span = bhi - blo; + if (!(span > 0.0)) continue; + uint8_t bit = msg[bperm[k] / reps]; + /* Solve for the exponent so the bin's mean of u=(rho-lo)/span lands on a fixed target either + * side of 0.5 (Cho-Prost-Jung mean modulation). mean(u^g) is monotonic-decreasing in g, so a + * fixed target makes blind decode (compare bin mean to 0.5) reliable regardless of the bin's + * natural mean. */ + double target = bit ? 0.62 : 0.38; + double glo = 1.0e-3, ghi = 1.0e3, gamma = 1.0; + for (int it = 0; it < 40; it++) { + gamma = 0.5 * (glo + ghi); + double gs = 0.0; size_t gc = 0u; + for (size_t r = lo; r < hi; r++) { + double uu = (pr[r].k - blo) / span; + if (uu < 0.0) uu = 0.0; if (uu > 1.0) uu = 1.0; + gs += pow(uu, gamma); gc++; + } + double gm = gc ? gs / (double)gc : 0.5; + if (gm > target) glo = gamma; else ghi = gamma; /* mean too high -> need larger exponent */ + } + for (size_t r = lo; r < hi; r++) { + size_t i = pr[r].i; + double rho = R[i]; + if (!(rho > 0.0)) continue; + double u = (rho - blo) / span; + if (u < 0.0) u = 0.0; if (u > 1.0) u = 1.0; + double nrho = blo + pow(u, gamma) * span; + double d = nrho - rho; + if (eps > 0.0) { if (d > eps) d = eps; if (d < -eps) d = -eps; } + nrho = rho + d; + double s = nrho / rho; + double nx = cx + s * (X[i] - cx), ny = cy + s * (Y[i] - cy), nz = cz + s * (Z[i] - cz); + wm_vset(vl.d[i].p, vl.d[i].i0, nx); + wm_vset(vl.d[i].p, vl.d[i].i0 + 1u, ny); + wm_vset(vl.d[i].p, vl.d[i].i0 + 2u, nz); + } + } + /* Restore the centroid: radial scaling shifts it, but the blind decoder recomputes the centroid, + * so the modified mesh must keep the original one for the norm reference to match. */ + double m1x = 0.0, m1y = 0.0, m1z = 0.0; + for (size_t i = 0u; i < N; i++) { + m1x += wm_vget(vl.d[i].p, vl.d[i].i0); + m1y += wm_vget(vl.d[i].p, vl.d[i].i0 + 1u); + m1z += wm_vget(vl.d[i].p, vl.d[i].i0 + 2u); + } + m1x /= (double)N; m1y /= (double)N; m1z /= (double)N; + double tcx = cx - m1x, tcy = cy - m1y, tcz = cz - m1z; + for (size_t i = 0u; i < N; i++) { + wm_vset(vl.d[i].p, vl.d[i].i0, wm_vget(vl.d[i].p, vl.d[i].i0) + tcx); + wm_vset(vl.d[i].p, vl.d[i].i0 + 1u, wm_vget(vl.d[i].p, vl.d[i].i0 + 1u) + tcy); + wm_vset(vl.d[i].p, vl.d[i].i0 + 2u, wm_vget(vl.d[i].p, vl.d[i].i0 + 2u) + tcz); + } + } else { + double* votes = (double*)calloc(B, sizeof(double)); + if (!votes) { rc = -7; goto done; } + for (size_t k = 0u; k < B; k++) { + size_t lo = k * N / B, hi = (k + 1u) * N / B; + if (hi <= lo) continue; + double blo = pr[lo].k, bhi = pr[hi - 1u].k, span = bhi - blo; + if (!(span > 0.0)) continue; + double sum = 0.0; + size_t cnt = 0u; + for (size_t r = lo; r < hi; r++) { + double u = (pr[r].k - blo) / span; + if (u < 0.0) u = 0.0; if (u > 1.0) u = 1.0; + sum += u; cnt++; + } + if (cnt == 0u) continue; + double mean = sum / (double)cnt; + double conf = (mean - 0.5) * 2.0; /* signed: >0 => bit 1 */ + votes[bperm[k]] = conf; + } + uint8_t mmsg[WM_MSG_BITS]; + for (size_t i = 0u; i < (size_t)WM_MSG_BITS; i++) { + double acc = 0.0; + for (size_t r = 0u; r < reps; r++) acc += votes[i * reps + r]; + mmsg[i] = (acc >= 0.0) ? 1u : 0u; + } + free(votes); + rc = wm_unframe(mmsg, out_hex); + if (rc != 0) rc = -10; + } + +done: + free(bperm); + free(X); free(Y); free(Z); free(R); free(pr); + wm_vlist_free(&vl); + return rc; +} + +/* Mesh v2 encode. token_hex = WM_TOKEN_HEX, seed_hex = WM_SEED_HEX (per-job). 0 ok, 1 skip, neg err. */ +XG_EXPORT int xg_0124(const char* file_path_utf8, const char* token_hex_utf8, const char* seed_hex_utf8) { + if (sodium_init() < 0) return -1; + if (!file_path_utf8 || !token_hex_utf8 || !seed_hex_utf8) return -2; + if (strlen(token_hex_utf8) != (size_t)WM_TOKEN_HEX || !hex_only(token_hex_utf8)) return -3; + uint8_t seed[WM_SEED_BYTES] = { 0 }; + if (strlen(seed_hex_utf8) != (size_t)WM_SEED_HEX || !hex_only(seed_hex_utf8) || wm_hex32(seed_hex_utf8, seed) != 0) { + sodium_memzero(seed, sizeof(seed)); + return -3; + } + + /* Per-file "can't carry this" conditions all SKIP (1), never block the import: unreadable bytes, + * non-binary/unparseable FBX, no usable vertices, or an embed that doesn't self-verify. Only + * misconfiguration (sodium/args/token) and a real disk write failure stay hard negatives. */ + uint8_t* b0 = NULL; + size_t n0 = 0; + xw_br br; + memset(&br, 0, sizeof(br)); + int br_ready = 0; + xw_o out; + memset(&out, 0, sizeof(out)); + int rc = 1; + if (read_all_bytes_utf8(file_path_utf8, &b0, &n0) != 0 || !b0) goto done; + if (!xw_fbx_bin(b0, n0)) goto done; /* ASCII/other: skip */ + + if (xw_br_parse(b0, n0, &br) != 0) goto done; + br_ready = 1; + + int er = wm_mesh_run(&br, token_hex_utf8, NULL, seed, sizeof(seed)); + if (er != 0) { rc = (er < 0) ? 1 : er; goto done; } /* uncarryable -> skip */ + + /* Self-verify with the real blind-decode path on the modified arrays; on miss, skip (don't write). */ + char chk[WM_TOKEN_HEX + 1]; + if (wm_mesh_run(&br, NULL, chk, seed, sizeof(seed)) != 0 || _stricmp(chk, token_hex_utf8) != 0) { + goto done; + } + + rc = 0; + for (size_t i = 0u; i < br.rn && rc == 0; i++) rc = xw_bn_build(&br.r[i], br.ver); + uint64_t off = 27u; + for (size_t i = 0u; i < br.rn && rc == 0; i++) { rc = xw_bn_assign(&br.r[i], br.ver, off); off = br.r[i].eo; } + + if (rc == 0) rc = xw_o_push(&out, br.h, sizeof(br.h)); + for (size_t i = 0u; i < br.rn && rc == 0; i++) rc = xw_bn_write(&br.r[i], br.ver, &out); + if (rc == 0) { uint8_t z25[25] = { 0 }; rc = xw_o_push(&out, z25, xw_fbx_null_record_size(br.ver)); } + if (rc == 0) rc = xw_o_push(&out, br.tail, br.tn); + if (rc == 0) rc = write_all_bytes_utf8(file_path_utf8, out.b, out.n); + rc = rc == 0 ? 0 : -8; + +done: + if (out.b) { sodium_memzero(out.b, out.c); free(out.b); } + if (br_ready) xw_br_free(&br); + if (b0) { + sodium_memzero(b0, n0 + 1u); + free(b0); + } + sodium_memzero(seed, sizeof(seed)); + return rc; +} + +/* Mesh v2 decode — server engine only (see decoder note above). */ +#if !defined(YUCP_RUNTIME_HELPER_EXPORTS) +/* seed_hex = WM_SEED_HEX (the job's seed). Writes token hex + NUL. 0 ok, negative on miss/error. */ +XG_EXPORT int xg_0125(const char* file_path_utf8, const char* seed_hex_utf8, char* out_hex_utf8, uint32_t out_cap) { + if (sodium_init() < 0) return -1; + if (!file_path_utf8 || !seed_hex_utf8 || !out_hex_utf8 || out_cap < (uint32_t)(WM_TOKEN_HEX + 1)) return -2; + uint8_t seed[WM_SEED_BYTES] = { 0 }; + if (strlen(seed_hex_utf8) != (size_t)WM_SEED_HEX || !hex_only(seed_hex_utf8) || wm_hex32(seed_hex_utf8, seed) != 0) { + sodium_memzero(seed, sizeof(seed)); + return -2; + } + + uint8_t* b0 = NULL; + size_t n0 = 0; + xw_br br; + memset(&br, 0, sizeof(br)); + int br_ready = 0; + int rc = -3; + if (read_all_bytes_utf8(file_path_utf8, &b0, &n0) != 0 || !b0) goto done; + if (!xw_fbx_bin(b0, n0)) { rc = -4; goto done; } + + if (xw_br_parse(b0, n0, &br) != 0) { rc = -5; goto done; } + br_ready = 1; + + char tok[WM_TOKEN_HEX + 1]; + int er = wm_mesh_run(&br, NULL, tok, seed, sizeof(seed)); + if (er != 0) { rc = -10; goto done; } + memcpy(out_hex_utf8, tok, (size_t)WM_TOKEN_HEX + 1u); + rc = 0; + +done: + if (br_ready) xw_br_free(&br); + if (b0) { + sodium_memzero(b0, n0 + 1u); + free(b0); + } + sodium_memzero(seed, sizeof(seed)); + return rc; +} +#endif /* !YUCP_RUNTIME_HELPER_EXPORTS — end mesh v2 decoder */ + XG_EXPORT int xg_0120(const char* file_path_utf8, const char* token_hex_utf8) { if (sodium_init() < 0) return -1; if (!file_path_utf8 || !token_hex_utf8) return -2; @@ -3857,6 +4639,8 @@ XG_EXPORT int xg_0120(const char* file_path_utf8, const char* token_hex_utf8) { return rc == 0 ? 0 : -12; } +/* Legacy FBX decoder — server engine only (see decoder note above). */ +#if !defined(YUCP_RUNTIME_HELPER_EXPORTS) XG_EXPORT int xg_0121(const char* file_path_utf8, uint32_t expected_hex_len, char* out_hex_utf8, uint32_t out_cap) { if (sodium_init() < 0) return -1; if (!file_path_utf8 || !out_hex_utf8) return -2; @@ -4033,6 +4817,7 @@ XG_EXPORT int xg_0121(const char* file_path_utf8, uint32_t expected_hex_len, cha free(b0); return rc == 0 ? 0 : -14; } +#endif /* !YUCP_RUNTIME_HELPER_EXPORTS — end legacy FBX decoder */ XG_EXPORT void xg_010f(void* hh) { if (!hh) return; diff --git a/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.sha256 b/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.sha256 index 186be7a..2c2d169 100644 --- a/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.sha256 +++ b/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.sha256 @@ -1 +1 @@ -acbc1fa9b91e5013588aeb4eb6a5041522f1450ec890809faea8a99c26a8ce38 \ No newline at end of file +a7d770d2084ede52d421bc1a76a37b9995d1db10e17d4b2d0be223930ea9dc63 \ No newline at end of file diff --git a/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.so b/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.so index fb1f830..cf6863b 100644 Binary files a/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.so and b/yucp_coupling/out/linux-x64/Release/runtime-helper/yucp_coupling.so differ diff --git a/yucp_coupling/out/linux-x64/Release/yucp_coupling.sha256 b/yucp_coupling/out/linux-x64/Release/yucp_coupling.sha256 index 6ef8f5c..a748f8c 100644 --- a/yucp_coupling/out/linux-x64/Release/yucp_coupling.sha256 +++ b/yucp_coupling/out/linux-x64/Release/yucp_coupling.sha256 @@ -1 +1 @@ -4e090e27074c8083b2db40beb4ec46faaede17689f509851cb01cf97909438ae \ No newline at end of file +906db3ffbdbf52cd5987390e8789a01084fb7f664844ef89402df72bdbcffa7d \ No newline at end of file diff --git a/yucp_coupling/out/linux-x64/Release/yucp_coupling.so b/yucp_coupling/out/linux-x64/Release/yucp_coupling.so index 94c5106..d5a8352 100644 Binary files a/yucp_coupling/out/linux-x64/Release/yucp_coupling.so and b/yucp_coupling/out/linux-x64/Release/yucp_coupling.so differ diff --git a/yucp_coupling/out/win-x64/Release/coupling_runtime.obj b/yucp_coupling/out/win-x64/Release/coupling_runtime.obj index 47bbb32..a04c2a1 100644 Binary files a/yucp_coupling/out/win-x64/Release/coupling_runtime.obj and b/yucp_coupling/out/win-x64/Release/coupling_runtime.obj differ diff --git a/yucp_coupling/out/win-x64/Release/runtime-helper/coupling_runtime.obj b/yucp_coupling/out/win-x64/Release/runtime-helper/coupling_runtime.obj index fa6af20..99e7f43 100644 Binary files a/yucp_coupling/out/win-x64/Release/runtime-helper/coupling_runtime.obj and b/yucp_coupling/out/win-x64/Release/runtime-helper/coupling_runtime.obj differ diff --git a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.compile.pdb b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.compile.pdb index 8e1772f..20b4cd5 100644 Binary files a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.compile.pdb and b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.compile.pdb differ diff --git a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.dll b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.dll index 680b56b..7455827 100644 Binary files a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.dll and b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.dll differ diff --git a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.pdb b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.pdb index 60f8bf3..4e0352e 100644 Binary files a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.pdb and b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.pdb differ diff --git a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.sha256 b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.sha256 index acf6950..5dda26e 100644 --- a/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.sha256 +++ b/yucp_coupling/out/win-x64/Release/runtime-helper/yucp_coupling.sha256 @@ -1 +1 @@ -55621d95fd3fa61c6547a2f80005aa9ead52c218640b867b176b016b6a441f82 +ec7e6abc52603bb788e387501f89a61391007bbe831882ca5f6aa9b07b065c8e diff --git a/yucp_coupling/out/win-x64/Release/yucp_coupling.compile.pdb b/yucp_coupling/out/win-x64/Release/yucp_coupling.compile.pdb index e16df4d..a9bed85 100644 Binary files a/yucp_coupling/out/win-x64/Release/yucp_coupling.compile.pdb and b/yucp_coupling/out/win-x64/Release/yucp_coupling.compile.pdb differ diff --git a/yucp_coupling/out/win-x64/Release/yucp_coupling.dll b/yucp_coupling/out/win-x64/Release/yucp_coupling.dll index a378ba7..3ffeb8a 100644 Binary files a/yucp_coupling/out/win-x64/Release/yucp_coupling.dll and b/yucp_coupling/out/win-x64/Release/yucp_coupling.dll differ diff --git a/yucp_coupling/out/win-x64/Release/yucp_coupling.pdb b/yucp_coupling/out/win-x64/Release/yucp_coupling.pdb index e610a54..be04172 100644 Binary files a/yucp_coupling/out/win-x64/Release/yucp_coupling.pdb and b/yucp_coupling/out/win-x64/Release/yucp_coupling.pdb differ diff --git a/yucp_coupling/out/win-x64/Release/yucp_coupling.sha256 b/yucp_coupling/out/win-x64/Release/yucp_coupling.sha256 index 0e36328..6b5482c 100644 --- a/yucp_coupling/out/win-x64/Release/yucp_coupling.sha256 +++ b/yucp_coupling/out/win-x64/Release/yucp_coupling.sha256 @@ -1 +1 @@ -1770e0a3d63e41add51709dafa5e0a865cf441204c44f7ee5b7ddc8d71672a36 +ba0a2b9b0164f894b78c24eca47c64833e4dc8afa58a21dcb8b39eaed8176f32 diff --git a/yucp_coupling/yucp_coupling.def b/yucp_coupling/yucp_coupling.def index 6f655f7..7c612ac 100644 --- a/yucp_coupling/yucp_coupling.def +++ b/yucp_coupling/yucp_coupling.def @@ -1,7 +1,9 @@ LIBRARY yucp_coupling EXPORTS - EntryPoint @1 - EntryPointW @2 - xg_0118 @3 - xg_0119 @4 - xg_0121 @5 + xg_0118 @1 + xg_0119 @2 + xg_0121 @3 + xg_0122 @4 + xg_0123 @5 + xg_0124 @6 + xg_0125 @7 diff --git a/yucp_coupling/yucp_coupling.runtime-helper.def b/yucp_coupling/yucp_coupling.runtime-helper.def index 1e01997..15a58aa 100644 --- a/yucp_coupling/yucp_coupling.runtime-helper.def +++ b/yucp_coupling/yucp_coupling.runtime-helper.def @@ -1,6 +1,6 @@ LIBRARY yucp_coupling EXPORTS - EntryPoint @1 - EntryPointW @2 - xg_0115 @3 - xg_0120 @4 + xg_0115 @1 + xg_0120 @2 + xg_0122 @3 + xg_0124 @4