diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac3636b..9b0e314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,5 +24,6 @@ jobs: npm ci npm run build --if-present npm test + npm run examples env: CI: true diff --git a/examples/unique-hash.eg.ts b/examples/unique-hash.eg.ts new file mode 100644 index 0000000..be90dc7 --- /dev/null +++ b/examples/unique-hash.eg.ts @@ -0,0 +1,106 @@ +import { Bytes } from 'o1js'; +import { + Spec, + Operation, + Claim, + Credential, + Presentation, + PresentationRequest, + assert, +} from '../src/index.ts'; +import { issuerKey, owner, ownerKey } from '../tests/test-utils.ts'; +import { validateCredential } from '../src/credential-index.ts'; +import { array } from '../src/o1js-missing.ts'; + +// example schema of the credential, which has enough entropy to be hashed into a unique id +const Bytes32 = Bytes(32); +const Bytes16 = Bytes(16); // 16 bytes = 128 bits = enough entropy + +const Data = { nationality: Bytes32, id: Bytes16 }; + +// --------------------------------------------- +// ISSUER: issue a signed credential to the owner + +let data = { + nationality: Bytes32.fromString('United States of America'), + id: Bytes16.random(), +}; +let credential = Credential.sign(issuerKey, { owner, data }); +let credentialJson = Credential.toJSON(credential); + +console.log('✅ ISSUER: issued credential:', credentialJson); + +// --------------------------------------------- +// WALLET: deserialize, validate and store the credential + +let storedCredential = Credential.fromJSON(credentialJson); + +await validateCredential(storedCredential); + +console.log('✅ WALLET: imported and validated credential'); + +// --------------------------------------------- +// VERIFIER: request a presentation + +const spec = Spec( + { + signedData: Credential.Simple(Data), // schema needed here! + targetNationalities: Claim(array(Bytes32, 3)), // TODO would make more sense as dynamic array + appId: Claim(Bytes32), + }, + ({ signedData, targetNationalities, appId }) => ({ + // we assert that the owner has the target nationality + // TODO: add a one-of-many operation to make this more interesting + assert: Operation.equalsOneOf( + Operation.property(signedData, 'nationality'), + targetNationalities + ), + // we expose a unique hash of the credential data, as nullifier + data: Operation.record({ + nullifier: Operation.hash(signedData, appId), + }), + }) +); + +const targetNationalities = ['United States of America', 'Canada', 'Mexico']; + +let request = PresentationRequest.noContext(spec, { + targetNationalities: targetNationalities.map((s) => Bytes32.fromString(s)), + appId: Bytes32.fromString('my-app-id:123'), +}); +let requestJson = PresentationRequest.toJSON(request); + +console.log('✅ VERIFIER: created presentation request:', requestJson); + +// --------------------------------------------- +// WALLET: deserialize request and create presentation + +console.time('compile'); +let deserialized = PresentationRequest.fromJSON(requestJson); +let compiled = await Presentation.compile(deserialized); +console.timeEnd('compile'); + +console.time('create'); +let presentation = await Presentation.create(ownerKey, { + request: compiled, + credentials: [storedCredential], +}); +console.timeEnd('create'); +// TODO: to send the presentation back we need to serialize it as well + +console.log('✅ WALLET: created presentation:', presentation); + +// --------------------------------------------- +// VERIFIER: verify the presentation, and check that the nullifier was not used yet + +let existingNullifiers = new Set([0x13c43f30n, 0x370f3473n, 0xe1fe0cdan]); + +// TODO: claims and other I/O values should be plain JS types +let { nullifier } = presentation.outputClaim; +assert( + !existingNullifiers.has(nullifier.toBigInt()), + 'Nullifier should be unique' +); +console.log('✅ VERIFIER: checked nullifier uniqueness'); + +// TODO: implement verification diff --git a/package.json b/package.json index 4e2960f..2f11c69 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format": "prettier --write --ignore-unknown **/*", "test": "node --test --experimental-strip-types --no-warnings {tests,src}/**/*.test.ts", "test-one": "node --enable-source-maps --test --experimental-strip-types --no-warnings", + "examples": "node --test --experimental-strip-types --no-warnings examples/*.eg.ts", "extension:dev": "vite build --config browser-extension/vite.config.js --watch", "extension:build": "vite build --config browser-extension/vite.config.js" }, diff --git a/src/credential-index.ts b/src/credential-index.ts index d0b8d49..9b03c8a 100644 --- a/src/credential-index.ts +++ b/src/credential-index.ts @@ -1,9 +1,32 @@ -import { createUnsigned, Unsigned } from './credential.ts'; -import { createSigned, Signed } from './credential-signed.ts'; import { type PublicKey } from 'o1js'; -import { Recursive } from './credential-recursive.ts'; +import { + createUnsigned, + type CredentialSpec, + type CredentialType, + hashCredential, + type StoredCredential, + Unsigned, +} from './credential.ts'; +import { + createSigned, + Signed, + type Witness as SignedWitness, +} from './credential-signed.ts'; +import { + Recursive, + type Witness as RecursiveWitness, +} from './credential-recursive.ts'; +import { assert, hasProperty } from './util.ts'; +import { + type InferNestedProvable, + NestedProvable, + type NestedProvablePure, +} from './nested.ts'; +import { assertPure } from './o1js-missing.ts'; +import { serializeNestedProvableValue } from './serialize-spec.ts'; +import { deserializeNestedProvableValue } from './deserialize-spec.ts'; -export { Credential }; +export { Credential, validateCredential }; /** * A credential is a generic piece of data (the "attributes") along with an owner represented by a public key. @@ -24,4 +47,77 @@ const Credential = { * Create a dummy credential with no owner and no signature. */ unsigned: createUnsigned, + + /** + * Serialize a credential to a JSON string. + */ + toJSON(credential: StoredCredential) { + let json = { + version: credential.version, + witness: serializeNestedProvableValue(credential.witness), + metadata: credential.metadata, + credential: serializeNestedProvableValue(credential.credential), + }; + return JSON.stringify(json); + }, + + /** + * Deserialize a credential from a JSON string. + */ + fromJSON(json: string): StoredCredential { + let obj = JSON.parse(json); + return { + version: obj.version, + witness: deserializeNestedProvableValue(obj.witness), + metadata: obj.metadata, + credential: deserializeNestedProvableValue(obj.credential), + }; + }, }; + +// validating generic credential + +type Witness = SignedWitness | RecursiveWitness; + +async function validateCredential( + credential: StoredCredential +) { + assert( + credential.version === 'v0', + `Unsupported credential version: ${credential.version}` + ); + + assert(knownWitness(credential.witness), 'Unknown credential type'); + + // TODO: this is brittle. probably data type should be part of metadata. + let data = NestedProvable.get( + NestedProvable.fromValue(credential.credential.data) + ); + assertPure(data); + let spec = getCredentialSpec(credential.witness)(data); + + let credHash = hashCredential(data, credential.credential); + await spec.verifyOutsideCircuit(credential.witness, credHash); +} + +const witnessTypes = new Set([ + 'simple', + 'recursive', +] satisfies Witness['type'][]); + +function knownWitness(witness: unknown): witness is Witness { + return hasProperty(witness, 'type') && witnessTypes.has(witness.type); +} + +function getCredentialSpec( + witness: W +): ( + dataType: DataType +) => CredentialSpec> { + switch (witness.type) { + case 'simple': + return Credential.Simple as any; + case 'recursive': + return Credential.Recursive.Generic as any; + } +} diff --git a/src/credential-recursive.ts b/src/credential-recursive.ts index 4614be1..36ea6ef 100644 --- a/src/credential-recursive.ts +++ b/src/credential-recursive.ts @@ -7,6 +7,7 @@ import { FeatureFlags, Proof, Poseidon, + verify, } from 'o1js'; import { assertPure, @@ -21,15 +22,17 @@ import { } from './nested.ts'; import { prefixes } from './constants.ts'; import { - type CredentialType, + type CredentialSpec, type Credential, type StoredCredential, HashableCredential, + defineCredential, } from './credential.ts'; +import { assert } from './util.ts'; -export { Recursive }; +export { Recursive, type Witness }; -type Witness = { +type Witness = { type: 'recursive'; vk: VerificationKey; proof: DynamicProof>; @@ -49,14 +52,14 @@ function Recursive< >( Proof: typeof DynamicProof>, dataType: DataType -): CredentialType<'proof', Witness, Data> { +): CredentialSpec<'recursive', Witness, Data> { // TODO annoying that this cast doesn't work without overriding the type let data: NestedProvablePureFor = dataType as any; const credentialType = HashableCredential(data); return { type: 'credential', - id: 'proof', + credentialType: 'recursive', witness: { type: ProvableType.constant('recursive' as const), vk: VerificationKey, @@ -70,6 +73,12 @@ function Recursive< let credential = credHash.unhash(); Provable.assertEqual(credentialType, proof.publicOutput, credential); }, + async verifyOutsideCircuit({ vk, proof }, credHash) { + let ok = await verify(proof, vk); + assert(ok, 'Invalid proof'); + let credential = credHash.unhash(); + Provable.assertEqual(credentialType, proof.publicOutput, credential); + }, // issuer == hash of vk and public input issuer({ vk, proof }) { @@ -84,7 +93,51 @@ function Recursive< }; } +const GenericRecursive = defineCredential({ + credentialType: 'recursive', + witness: { + type: ProvableType.constant('recursive' as const), + vk: VerificationKey, + proof: DynamicProof, + }, + + // verify the proof, check that its public output is exactly the credential + verify({ vk, proof }, credHash) { + proof.verify(vk); + let credential = credHash.unhash(); + Provable.assertEqual( + (proof.constructor as typeof DynamicProof).publicOutputType, + proof.publicOutput, + credential + ); + }, + async verifyOutsideCircuit({ vk, proof }, credHash) { + let ok = await verify(proof, vk); + assert(ok, 'Invalid proof'); + let credential = credHash.unhash(); + Provable.assertEqual( + (proof.constructor as typeof DynamicProof).publicOutputType, + proof.publicOutput, + credential + ); + }, + + // issuer == hash of vk and public input + issuer({ vk, proof }) { + let credIdent = Poseidon.hash( + (proof.constructor as typeof DynamicProof).publicInputType.toFields( + proof.publicInput + ) + ); + return Poseidon.hashWithPrefix(prefixes.issuerRecursive, [ + vk.hash, + credIdent, + ]); + }, +}); + Recursive.fromProgram = RecursiveFromProgram; +Recursive.Generic = GenericRecursive; async function RecursiveFromProgram< DataType extends ProvablePure, diff --git a/src/credential-signed.ts b/src/credential-signed.ts index 2a9865d..9f27f26 100644 --- a/src/credential-signed.ts +++ b/src/credential-signed.ts @@ -26,7 +26,7 @@ type Metadata = undefined; type Signed = StoredCredential; const Signed = defineCredential({ - id: 'signature-native', + credentialType: 'simple', witness: { type: ProvableType.constant('simple' as const), issuer: PublicKey, @@ -38,6 +38,10 @@ const Signed = defineCredential({ let ok = issuerSignature.verify(issuer, [credHash.hash]); ok.assertTrue('Invalid signature'); }, + async verifyOutsideCircuit({ issuer, issuerSignature }, credHash) { + let ok = issuerSignature.verify(issuer, [credHash.hash]); + ok.assertTrue('Invalid signature'); + }, // issuer == issuer public key issuer({ issuer }) { diff --git a/src/credential.ts b/src/credential.ts index 6214322..8a0cad8 100644 --- a/src/credential.ts +++ b/src/credential.ts @@ -19,8 +19,8 @@ import { zip } from './util.ts'; export { type Credential, + type CredentialSpec, type CredentialType, - type CredentialId, type CredentialInputs, type CredentialOutputs, hashCredential, @@ -43,28 +43,34 @@ type Credential = { owner: PublicKey; data: Data }; /** * The different types of credential we currently support. */ -type CredentialId = 'none' | 'signature-native' | 'proof'; +type CredentialType = 'unsigned' | 'simple' | 'recursive'; /** * A credential type is: * - a string id fully identifying the credential type * - a type for private parameters * - a type for data (which is left generic when defining credential types) - * - a function `verify(...)` that asserts the credential is valid + * - a function `verify(...)` that verifies the credential inside a ZkProgram circuit + * - a function `verifyOutsideCircuit(...)` that verifies the credential in normal JS * - a function `issuer(...)` that derives a commitment to the "issuer" of the credential, e.g. a public key for signed credentials */ -type CredentialType< - Id extends CredentialId = CredentialId, +type CredentialSpec< + Type extends CredentialType = CredentialType, Witness = any, Data = any > = { type: 'credential'; - id: Id; + credentialType: Type; witness: NestedProvableFor; data: NestedProvablePureFor; verify(witness: Witness, credHash: Hashed>): void; + verifyOutsideCircuit( + witness: Witness, + credHash: Hashed> + ): Promise; + issuer(witness: Witness): Field; }; @@ -93,7 +99,7 @@ type CredentialInputs = { ownerSignature: Signature; credentials: { - credentialType: CredentialType; + spec: CredentialSpec; credential: Credential; witness: any; }[]; @@ -116,22 +122,18 @@ function verifyCredentials({ credentials, }: CredentialInputs): CredentialOutputs { // pack credentials in hashes - let credHashes = credentials.map(({ credentialType: { data }, credential }) => + let credHashes = credentials.map(({ spec: { data }, credential }) => hashCredential(data, credential) ); // verify each credential using its own verification method - zip(credentials, credHashes).forEach( - ([{ credentialType, witness }, credHash]) => { - credentialType.verify(witness, credHash); - } - ); + zip(credentials, credHashes).forEach(([{ spec, witness }, credHash]) => { + spec.verify(witness, credHash); + }); // create issuer hashes for each credential // TODO would be nice to make this a `Hashed` over a more informative `Issuer` type, for easier use in the app circuit - let issuers = credentials.map(({ credentialType, witness }) => - credentialType.issuer(witness) - ); + let issuers = credentials.map(({ spec, witness }) => spec.issuer(witness)); // assert that all credentials have the same owner, and determine that owner let owner: undefined | PublicKey; @@ -166,7 +168,7 @@ function signCredentials( ownerKey: PrivateKey, context: Field, ...credentials: { - credentialType: CredentialType; + credentialType: CredentialSpec; credential: Credential; witness: Private; }[] @@ -182,32 +184,38 @@ function signCredentials( } function defineCredential< - Id extends CredentialId, - PrivateType extends NestedProvable + Type extends CredentialType, + Witness extends NestedProvable >(config: { - id: Id; - witness: PrivateType; + credentialType: Type; + witness: Witness; verify( - witness: InferNestedProvable, + witness: InferNestedProvable, credHash: Hashed> ): void; - issuer(witness: InferNestedProvable): Field; + verifyOutsideCircuit( + witness: InferNestedProvable, + credHash: Hashed> + ): Promise; + + issuer(witness: InferNestedProvable): Field; }) { return function credential( dataType: DataType - ): CredentialType< - Id, - InferNestedProvable, + ): CredentialSpec< + Type, + InferNestedProvable, InferNestedProvable > { return { type: 'credential', - id: config.id, + credentialType: config.credentialType, witness: config.witness as any, data: dataType as any, verify: config.verify, + verifyOutsideCircuit: config.verifyOutsideCircuit, issuer: config.issuer, }; }; @@ -217,11 +225,12 @@ function defineCredential< type Unsigned = StoredCredential; const Unsigned = defineCredential({ - id: 'none', + credentialType: 'unsigned', witness: Undefined, // do nothing verify() {}, + async verifyOutsideCircuit() {}, // dummy issuer issuer() { diff --git a/src/deserialize-spec.ts b/src/deserialize-spec.ts index 4a08fc9..9b1a64d 100644 --- a/src/deserialize-spec.ts +++ b/src/deserialize-spec.ts @@ -24,9 +24,9 @@ import { type O1jsTypeName, type SerializedProvableType, } from './serialize-spec.ts'; -import { type CredentialId } from './credential.ts'; +import { type CredentialType } from './credential.ts'; import { Credential } from './credential-index.ts'; -import { ProvableType } from './o1js-missing.ts'; +import { array, ProvableType } from './o1js-missing.ts'; import { PresentationRequest } from './presentation.ts'; export { @@ -38,6 +38,7 @@ export { deserializeProvable, deserializeNestedProvable, deserializePresentationRequest, + deserializeNestedProvableValue, deserializeInputContext, }; @@ -70,18 +71,21 @@ function deserializeInputContext(context: { }) { return { type: context.type as 'zk-app' | 'https', - presentationCircuitVKHash: deserializeProvable( - 'Field', - context.presentationCircuitVKHash.value - ), + presentationCircuitVKHash: deserializeProvable({ + _type: 'Field', + value: context.presentationCircuitVKHash.value, + }), action: context.type === 'zk-app' - ? deserializeProvable( - 'Field', - (context.action as { _type: string; value: string }).value - ) + ? deserializeProvable({ + _type: 'Field', + value: (context.action as { _type: string; value: string }).value, + }) : (context.action as string), - serverNonce: deserializeProvable('Field', context.serverNonce.value), + serverNonce: deserializeProvable({ + _type: 'Field', + value: context.serverNonce.value, + }), }; } @@ -118,23 +122,23 @@ function deserializeInput(input: any): Input { case 'constant': return Constant( deserializeProvableType(input.data), - deserializeProvable(input.data._type, input.value) + deserializeProvable({ _type: input.data._type, value: input.value }) ); - case 'public': + case 'claim': return Claim(deserializeNestedProvablePure(input.data)); case 'credential': { - let id: CredentialId = input.id; + let credentialType: CredentialType = input.credentialType; let data = deserializeNestedProvablePure(input.data); - switch (id) { - case 'signature-native': + switch (credentialType) { + case 'simple': return Credential.Simple(data); - case 'none': + case 'unsigned': return Credential.Unsigned(data); - case 'proof': + case 'recursive': let proof = deserializeProvableType(input.witness.proof) as any; return Credential.Recursive(proof, data); default: - throw Error(`Unsupported credential id: ${id}`); + throw Error(`Unsupported credential id: ${credentialType}`); } } default: @@ -142,7 +146,11 @@ function deserializeInput(input: any): Input { } } -function deserializeNode(input: any, node: any): Node { +function deserializeNode(input: any, node: any): Node; +function deserializeNode( + input: any, + node: { type: Node['type'] } & Record +): Node { switch (node.type) { case 'owner': { return { @@ -158,7 +166,7 @@ function deserializeNode(input: any, node: any): Node { case 'constant': return { type: 'constant', - data: deserializeProvable(node.data._type, node.data.value), + data: deserializeProvable(node.data), }; case 'root': return { type: 'root', input }; @@ -176,6 +184,15 @@ function deserializeNode(input: any, node: any): Node { left: deserializeNode(input, node.left), right: deserializeNode(input, node.right), }; + case 'equalsOneOf': { + return { + type: 'equalsOneOf', + input: deserializeNode(input, node.input), + options: Array.isArray(node.options) + ? node.options.map((o) => deserializeNode(input, o)) + : deserializeNode(input, node.options), + }; + } case 'and': case 'or': case 'add': @@ -187,8 +204,14 @@ function deserializeNode(input: any, node: any): Node { left: deserializeNode(input, node.left), right: deserializeNode(input, node.right), }; - case 'not': case 'hash': + let result: Node = { + type: node.type, + inputs: node.inputs.map((i: any) => deserializeNode(input, i)), + }; + if (node.prefix !== null) result.prefix = node.prefix; + return result; + case 'not': return { type: node.type, inner: deserializeNode(input, node.inner), @@ -203,7 +226,7 @@ function deserializeNode(input: any, node: any): Node { case 'record': const deserializedData: Record = {}; for (const [key, value] of Object.entries(node.data)) { - deserializedData[key] = deserializeNode(input, value); + deserializedData[key] = deserializeNode(input, value as any); } return { type: 'record', @@ -238,6 +261,10 @@ function deserializeProvableType( let properties = deserializeNestedProvable(type.properties); return Struct(properties); } + if (type._type === 'Array') { + let inner = deserializeProvableType(type.inner); + return array(inner, type.size); + } if (type._type === 'String') { return String as any; } @@ -246,8 +273,14 @@ function deserializeProvableType( return result; } -function deserializeProvable(type: string, value: string): any { - switch (type) { +function deserializeProvable({ + _type, + value, +}: { + _type: string; + value: any; +}): any { + switch (_type) { case 'Field': return Field.fromJSON(value); case 'Bool': @@ -264,8 +297,10 @@ function deserializeProvable(type: string, value: string): any { return Signature.fromJSON(value); case 'Bytes': return Bytes.fromHex(value); + case 'Array': + return (value as any[]).map((v: any) => deserializeProvable(v)); default: - throw Error(`Unsupported provable type: ${type}`); + throw Error(`Unsupported provable type: ${_type}`); } } @@ -310,20 +345,24 @@ function deserializeNestedProvablePure(type: any): NestedProvablePure { throw Error(`Invalid type in NestedProvablePure: ${type}`); } -function deserializeNestedProvableValue(type: any): any { - if (typeof type === 'object' && type !== null) { - if ('_type' in type) { +function deserializeNestedProvableValue(value: any): any { + if (typeof value === 'string') return value; + + if (typeof value === 'object' && value !== null) { + if ('_type' in value) { // basic provable type - return deserializeProvable(type._type, type.value); + return deserializeProvable(value); } else { // nested object const result: Record = {}; - for (const [key, value] of Object.entries(type)) { - result[key] = deserializeNestedProvableValue(value); + for (let [key, v] of Object.entries(value)) { + result[key] = deserializeNestedProvableValue(v); } return result; } } + + throw Error(`Invalid nested provable value: ${value}`); } function replaceNull(obj: Record): Record { diff --git a/src/index.ts b/src/index.ts index 7aca2ae..452d876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -// run with node --experimental-strip-types -import './program-spec.ts'; -import './program.ts'; -import './types.ts'; +export { Spec, Operation, Claim, Constant } from './program-spec.ts'; +export { Credential } from './credential-index.ts'; +export { Presentation, PresentationRequest } from './presentation.ts'; +export { assert } from './util.ts'; diff --git a/src/nested.ts b/src/nested.ts index f5b5b4b..951c20c 100644 --- a/src/nested.ts +++ b/src/nested.ts @@ -3,7 +3,7 @@ * wrap types in `Struct` and similar. */ import { type InferProvable, Provable, type ProvablePure, Struct } from 'o1js'; -import { type ProvablePureType, ProvableType } from './o1js-missing.ts'; +import { array, type ProvablePureType, ProvableType } from './o1js-missing.ts'; import { assertIsObject } from './util.ts'; export { NestedProvable }; @@ -35,6 +35,9 @@ const NestedProvable = { // case 2: value is a record of values from provable types if (typeof value === 'string') return String as any; + if (Array.isArray(value)) + return array(NestedProvable.fromValue(value[0]), value.length) as any; + assertIsObject(value); return Object.fromEntries( Object.entries(value).map(([key, value]) => [ diff --git a/src/o1js-missing.ts b/src/o1js-missing.ts index c9e019c..1b3277e 100644 --- a/src/o1js-missing.ts +++ b/src/o1js-missing.ts @@ -5,17 +5,21 @@ import { Bool, Field, type InferProvable, + type InferValue, Provable, type ProvablePure, + Struct, Undefined, } from 'o1js'; import { assert, assertHasProperty, hasProperty } from './util.ts'; +import type { NestedProvable } from './nested.ts'; export { ProvableType, assertPure, type ProvablePureType, type InferProvableType, + array, }; const ProvableType = { @@ -34,6 +38,9 @@ const ProvableType = { if (value === undefined) return Undefined as any; if (value instanceof Field) return Field as any; if (value instanceof Bool) return Bool as any; + if (Array.isArray(value)) + return array(ProvableType.fromValue(value[0]), value.length) as any; + assertHasProperty( value, 'constructor', @@ -119,3 +126,79 @@ type ToProvable> = A extends { ? P : A; type InferProvableType = InferProvable>; + +// temporary, until we land `StaticArray` +// this is copied from o1js and then modified: https://github.com/o1-labs/o1js +// License here: https://github.com/o1-labs/o1js/blob/main/LICENSE +function array(elementType: A, length: number) { + type T = InferProvable; + type V = InferValue; + let type: Provable = ProvableType.isProvableType(elementType) + ? ProvableType.get(elementType) + : Struct(elementType); + return { + _isArray: true, + innerType: elementType, + size: length, + + /** + * Returns the size of this structure in {@link Field} elements. + * @returns size of this structure + */ + sizeInFields() { + let elementLength = type.sizeInFields(); + return elementLength * length; + }, + /** + * Serializes this structure into {@link Field} elements. + * @returns an array of {@link Field} elements + */ + toFields(array: T[]) { + return array.map((e) => type.toFields(e)).flat(); + }, + /** + * Serializes this structure's auxiliary data. + * @returns auxiliary data + */ + toAuxiliary(array?) { + let array_ = array ?? Array(length).fill(undefined); + return array_?.map((e) => type.toAuxiliary(e)); + }, + + /** + * Deserializes an array of {@link Field} elements into this structure. + */ + fromFields(fields: Field[], aux?: any[]) { + let array = []; + let size = type.sizeInFields(); + let n = length; + for (let i = 0, offset = 0; i < n; i++, offset += size) { + array[i] = type.fromFields( + fields.slice(offset, offset + size), + aux?.[i] + ); + } + return array; + }, + check(array: T[]) { + for (let i = 0; i < length; i++) { + (type as any).check(array[i]); + } + }, + toCanonical(x) { + return x.map((v) => Provable.toCanonical(type, v)); + }, + + toValue(x) { + return x.map((v) => type.toValue(v)); + }, + + fromValue(x) { + return x.map((v) => type.fromValue(v)); + }, + } satisfies Provable & { + _isArray: true; + innerType: A; + size: number; + }; +} diff --git a/src/presentation.ts b/src/presentation.ts index d57a357..ef81343 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -8,7 +8,7 @@ import { import { createProgram, type Program } from './program.ts'; import { signCredentials, - type CredentialType, + type CredentialSpec, type StoredCredential, } from './credential.ts'; import { assert } from './util.ts'; @@ -187,7 +187,7 @@ async function createPresentation>( let { program } = await Presentation.compile(request); let credentialsNeeded = Object.entries(request.spec.inputs).filter( - (c): c is [string, CredentialType] => c[1].type === 'credential' + (c): c is [string, CredentialSpec] => c[1].type === 'credential' ); let credentialsUsed = pickCredentials( credentialsNeeded.map(([key]) => key), diff --git a/src/program-spec.ts b/src/program-spec.ts index 4ac29b5..22ce75b 100644 --- a/src/program-spec.ts +++ b/src/program-spec.ts @@ -18,7 +18,7 @@ import { type InferProvableType, ProvableType, } from './o1js-missing.ts'; -import { assert, assertHasProperty } from './util.ts'; +import { assert, assertHasProperty, zip } from './util.ts'; import { type InferNestedProvable, NestedProvable, @@ -27,8 +27,8 @@ import { type NestedProvablePureFor, } from './nested.ts'; import { + type CredentialSpec, type CredentialType, - type CredentialId, type Credential, type CredentialInputs, withOwner, @@ -127,6 +127,7 @@ const Operation = { property, record, equals, + equalsOneOf, lessThan, lessThanEq, add, @@ -137,6 +138,7 @@ const Operation = { or, not, hash, + hashWithPrefix, ifThenElse, compute, }; @@ -149,7 +151,7 @@ type Constant = { type Claim = { type: 'claim'; data: NestedProvablePureFor }; type Input = - | CredentialType + | CredentialSpec | Constant | Claim; @@ -161,6 +163,7 @@ type Node = | { type: 'property'; key: string; inner: Node } | { type: 'record'; data: Record } | { type: 'equals'; left: Node; right: Node } + | { type: 'equalsOneOf'; input: Node; options: Node[] | Node } | { type: 'lessThan'; left: Node; right: Node } | { type: 'lessThanEq'; left: Node; right: Node } | { type: 'add'; left: Node; right: Node } @@ -170,7 +173,7 @@ type Node = | { type: 'and'; left: Node; right: Node } | { type: 'or'; left: Node; right: Node } | { type: 'not'; inner: Node } - | { type: 'hash'; inner: Node } + | { type: 'hash'; inputs: Node[]; prefix?: string } | { type: 'ifThenElse'; condition: Node; @@ -233,6 +236,18 @@ function evalNode(root: object, node: Node): Data { let bool = Provable.equal(ProvableType.fromValue(left), left, right); return bool as Data; } + case 'equalsOneOf': { + let input = evalNode(root, node.input); + let type = NestedProvable.get(NestedProvable.fromValue(input)); + let options: any[]; + if (Array.isArray(node.options)) { + options = node.options.map((i) => evalNode(root, i)); + } else { + options = evalNode(root, node.options); + } + let bools = options.map((o) => Provable.equal(type, input, o)); + return bools.reduce(Bool.or) as Data; + } case 'lessThan': case 'lessThanEq': return compareNodes(root, node, node.type === 'lessThanEq') as Data; @@ -255,11 +270,18 @@ function evalNode(root: object, node: Node): Data { let inner = evalNode(root, node.inner); return inner.not() as Data; } - // TODO: handle composite types case 'hash': { - let inner = evalNode(root, node.inner); - let innerFields = inner.toFields(); - let hash = Poseidon.hash(innerFields); + let inputs = node.inputs.map((i) => evalNode(root, i)); + let types = inputs.map((i) => + NestedProvable.get(NestedProvable.fromValue(i)) + ); + let fields = zip(types, inputs).flatMap(([type, value]) => + type.toFields(value) + ); + let hash = + node.prefix === undefined + ? Poseidon.hash(fields) + : Poseidon.hashWithPrefix(node.prefix, fields); return hash as Data; } case 'ifThenElse': { @@ -370,6 +392,7 @@ function evalNodeType(rootType: NestedProvable, node: Node): NestedProvable { return inner[node.key] as any; } case 'equals': + case 'equalsOneOf': case 'lessThan': case 'lessThanEq': case 'and': @@ -456,6 +479,13 @@ function equals(left: Node, right: Node): Node { return { type: 'equals', left, right }; } +function equalsOneOf( + input: Node, + options: Node[] | Node +): Node { + return { type: 'equalsOneOf', input, options }; +} + type NumericType = Field | UInt64 | UInt32 | UInt8; const numericTypeOrder = [UInt8, UInt32, UInt64, Field]; @@ -514,8 +544,11 @@ function not(inner: Node): Node { return { type: 'not', inner }; } -function hash(inner: Node): Node { - return { type: 'hash', inner }; +function hash(...inputs: Node[]): Node { + return { type: 'hash', inputs }; +} +function hashWithPrefix(prefix: string, ...inputs: Node[]): Node { + return { type: 'hash', inputs, prefix }; } function issuer(credential: Node): Node { @@ -638,7 +671,7 @@ function extractCredentialInputs( if (input.type === 'credential') { let value: any = credentials[key]; credentialInputs.push({ - credentialType: input, + spec: input, credential: value.credential, witness: value.witness, }); @@ -729,16 +762,16 @@ type MapToDataInput> = { type ToClaim = T extends Claim ? Data : never; -type ToCredential = T extends CredentialType< - CredentialId, +type ToCredential = T extends CredentialSpec< + CredentialType, infer Witness, infer Data > ? { credential: Credential; witness: Witness } : never; -type ToDataInput = T extends CredentialType< - CredentialId, +type ToDataInput = T extends CredentialSpec< + CredentialType, any, infer Data > diff --git a/src/serialize-spec.ts b/src/serialize-spec.ts index c4af578..85f45cc 100644 --- a/src/serialize-spec.ts +++ b/src/serialize-spec.ts @@ -55,6 +55,7 @@ export { serializeSpec, validateSpecHash, serializePresentationRequest, + serializeNestedProvableValue, serializeInputContext, }; @@ -98,11 +99,7 @@ function convertSpecToSerializable(spec: Spec): Record { function serializeInputs(inputs: Record): Record { return Object.fromEntries( - // sort by keys so we always get the same serialization for the same spec - // will be important for hashing - Object.keys(inputs) - .sort() - .map((key) => [key, serializeInput(inputs[key]!)]) + Object.keys(inputs).map((key) => [key, serializeInput(inputs[key]!)]) ); } @@ -112,7 +109,7 @@ function serializeInput(input: Input): any { case 'credential': { return { type: 'credential', - id: input.id, + credentialType: input.credentialType, witness: serializeNestedProvable(input.witness), data: serializeNestedProvable(input.data), }; @@ -126,7 +123,7 @@ function serializeInput(input: Input): any { } case 'claim': { return { - type: 'public', + type: 'claim', data: serializeNestedProvable(input.data), }; } @@ -135,7 +132,7 @@ function serializeInput(input: Input): any { throw Error('Invalid input type'); } -function serializeNode(node: Node): any { +function serializeNode(node: Node): object { switch (node.type) { case 'owner': { return { @@ -178,7 +175,21 @@ function serializeNode(node: Node): any { left: serializeNode(node.left), right: serializeNode(node.right), }; + case 'equalsOneOf': { + return { + type: 'equalsOneOf', + input: serializeNode(node.input), + options: Array.isArray(node.options) + ? node.options.map(serializeNode) + : serializeNode(node.options), + }; + } case 'hash': + return { + type: node.type, + inputs: node.inputs.map(serializeNode), + prefix: node.prefix ?? null, + }; case 'not': return { type: node.type, @@ -201,6 +212,8 @@ function serializeNode(node: Node): any { data: serializedData, }; } + default: + throw Error(`Invalid node type: ${(node as Node).type}`); } } @@ -242,6 +255,7 @@ function serializeInputContext(context: { type SerializedType = | { _type: O1jsTypeName } | { _type: 'Struct'; properties: SerializedNestedType } + | { _type: 'Array'; inner: SerializedType; size: number } | { _type: 'Constant'; value: unknown } | { _type: 'Bytes'; size: number } | { _type: 'Proof'; proof: Record } @@ -274,6 +288,13 @@ function serializeProvableType(type: ProvableType): SerializedType { if (_type === undefined && (type as any)._isStruct) { return serializeStruct(type as Struct); } + if (_type === undefined && (type as any)._isArray) { + return { + _type: 'Array', + inner: serializeProvableType((type as any).innerType), + size: (type as any).size, + }; + } assert( _type !== undefined, `serializeProvableType: Unsupported provable type: ${type}` @@ -281,12 +302,15 @@ function serializeProvableType(type: ProvableType): SerializedType { return { _type }; } -function serializeProvable(value: any): { _type: string; value: string } { +function serializeProvable(value: any): { _type: string; value: any } { let typeClass = ProvableType.fromValue(value); let { _type } = serializeProvableType(typeClass); if (_type === 'Bytes') { return { _type, value: (value as Bytes).toHex() }; } + if (_type === 'Array') { + return { _type, value: value.map((x: any) => serializeProvable(x)) }; + } switch (typeClass) { case Bool: { return { _type, value: value.toJSON().toString() }; @@ -306,15 +330,12 @@ function serializeStruct(type: Struct): SerializedType { for (let key in value) { let type = NestedProvable.fromValue(value[key]); - properties[key] = serializeNestedProvable(type, false); + properties[key] = serializeNestedProvable(type); } return { _type: 'Struct', properties }; } -function serializeNestedProvable( - type: NestedProvable, - reorderKeys = true -): SerializedNestedType { +function serializeNestedProvable(type: NestedProvable): SerializedNestedType { if (ProvableType.isProvableType(type)) { return serializeProvableType(type); } @@ -324,13 +345,8 @@ function serializeNestedProvable( if (typeof type === 'object' && type !== null) { const serializedObject: Record = {}; - // sort by keys so we always get the same serialization for the same spec - // will be important for hashing - let keys = Object.keys(type); - if (reorderKeys) keys = keys.sort(); - - for (const key of keys) { - serializedObject[key] = serializeNestedProvable(type[key]!, reorderKeys); + for (const key of Object.keys(type)) { + serializedObject[key] = serializeNestedProvable(type[key]!); } return serializedObject; } @@ -350,19 +366,19 @@ function serializeNestedProvableTypeAndValue(t: { if (ProvableType.isProvableType(t.type)) { return serializeProvable(t.value); } + if (typeof t.type === 'string' || (t.type as any) === String) return t.value; + return Object.fromEntries( - Object.keys(t.type) - .sort() - .map((key) => { - assert(key in t.value, `Missing value for key ${key}`); - return [ - key, - serializeNestedProvableTypeAndValue({ - type: (t.type as any)[key], - value: t.value[key], - }), - ]; - }) + Object.keys(t.type).map((key) => { + assert(key in t.value, `Missing value for key ${key}`); + return [ + key, + serializeNestedProvableTypeAndValue({ + type: (t.type as any)[key], + value: t.value[key], + }), + ]; + }) ); } diff --git a/tests/deserialize.test.ts b/tests/deserialize.test.ts index 476ae23..8790b2c 100644 --- a/tests/deserialize.test.ts +++ b/tests/deserialize.test.ts @@ -38,7 +38,7 @@ import { zkAppVerifierIdentity } from './test-utils.ts'; test('Deserialize Spec', async (t) => { await t.test('deserializeProvable', async (t) => { await t.test('Field', () => { - const deserialized = deserializeProvable('Field', '42'); + const deserialized = deserializeProvable({ _type: 'Field', value: '42' }); assert(deserialized instanceof Field, 'Should be instance of Field'); assert.strictEqual( deserialized.toString(), @@ -48,11 +48,17 @@ test('Deserialize Spec', async (t) => { }); await t.test('Bool', () => { - const deserializedTrue = deserializeProvable('Bool', 'true'); + const deserializedTrue = deserializeProvable({ + _type: 'Bool', + value: 'true', + }); assert(deserializedTrue instanceof Bool, 'Should be instance of Bool'); assert.strictEqual(deserializedTrue.toBoolean(), true, 'Should be true'); - const deserializedFalse = deserializeProvable('Bool', 'false'); + const deserializedFalse = deserializeProvable({ + _type: 'Bool', + value: 'false', + }); assert(deserializedFalse instanceof Bool, 'Should be instance of Bool'); assert.strictEqual( deserializedFalse.toBoolean(), @@ -62,7 +68,10 @@ test('Deserialize Spec', async (t) => { }); await t.test('UInt8', () => { - const deserialized = deserializeProvable('UInt8', '255'); + const deserialized = deserializeProvable({ + _type: 'UInt8', + value: '255', + }); assert(deserialized instanceof UInt8, 'Should be instance of UInt8'); assert.strictEqual( deserialized.toString(), @@ -72,7 +81,10 @@ test('Deserialize Spec', async (t) => { }); await t.test('UInt32', () => { - const deserialized = deserializeProvable('UInt32', '4294967295'); + const deserialized = deserializeProvable({ + _type: 'UInt32', + value: '4294967295', + }); assert(deserialized instanceof UInt32, 'Should be instance of UInt32'); assert.strictEqual( deserialized.toString(), @@ -82,10 +94,10 @@ test('Deserialize Spec', async (t) => { }); await t.test('UInt64', () => { - const deserialized = deserializeProvable( - 'UInt64', - '18446744073709551615' - ); + const deserialized = deserializeProvable({ + _type: 'UInt64', + value: '18446744073709551615', + }); assert(deserialized instanceof UInt64, 'Should be instance of UInt64'); assert.strictEqual( deserialized.toString(), @@ -97,7 +109,10 @@ test('Deserialize Spec', async (t) => { await t.test('PublicKey', () => { const publicKeyBase58 = 'B62qiy32p8kAKnny8ZFwoMhYpBppM1DWVCqAPBYNcXnsAHhnfAAuXgg'; - const deserialized = deserializeProvable('PublicKey', publicKeyBase58); + const deserialized = deserializeProvable({ + _type: 'PublicKey', + value: publicKeyBase58, + }); assert( deserialized instanceof PublicKey, 'Should be instance of PublicKey' @@ -120,10 +135,7 @@ test('Deserialize Spec', async (t) => { const serializedSignature = serializeProvable(signature); // Deserialize the signature - const deserialized = deserializeProvable( - serializedSignature._type, - serializedSignature.value - ); + const deserialized = deserializeProvable(serializedSignature); assert( deserialized instanceof Signature, @@ -145,7 +157,7 @@ test('Deserialize Spec', async (t) => { await t.test('Invalid type', () => { assert.throws( - () => deserializeProvable('InvalidType' as any, '42'), + () => deserializeProvable({ _type: 'InvalidType' as any, value: '42' }), { message: 'Unsupported provable type: InvalidType' }, 'Should throw for invalid type' ); diff --git a/tests/serialize.test.ts b/tests/serialize.test.ts index 1b965b7..a6d6023 100644 --- a/tests/serialize.test.ts +++ b/tests/serialize.test.ts @@ -338,7 +338,8 @@ test('Serialize Nodes', async (t) => { const expected = { type: 'hash', - inner: { type: 'constant', data: { _type: 'Field', value: '123' } }, + inputs: [{ type: 'constant', data: { _type: 'Field', value: '123' } }], + prefix: null, }; assert.deepStrictEqual(serialized, expected); @@ -435,7 +436,7 @@ test('serializeInput', async (t) => { const serialized = serializeInput(input); const expected = { - type: 'public', + type: 'claim', data: { _type: 'Field' }, }; @@ -449,7 +450,7 @@ test('serializeInput', async (t) => { const expected = { type: 'credential', - id: 'none', + credentialType: 'unsigned', witness: { _type: 'Undefined' }, data: { _type: 'Field' }, }; @@ -464,7 +465,7 @@ test('serializeInput', async (t) => { const expected = { type: 'credential', - id: 'signature-native', + credentialType: 'simple', witness: { type: { type: 'Constant', value: 'simple' }, issuer: { _type: 'PublicKey' }, @@ -493,7 +494,7 @@ test('serializeInput', async (t) => { const expected = { type: 'credential', - id: 'none', + credentialType: 'unsigned', witness: { _type: 'Undefined' }, data: { personal: { @@ -536,11 +537,11 @@ test('convertSpecToSerializable', async (t) => { inputs: { age: { type: 'credential', - id: 'none', + credentialType: 'unsigned', witness: { _type: 'Undefined' }, data: { _type: 'Field' }, }, - isAdmin: { type: 'public', data: { _type: 'Bool' } }, + isAdmin: { type: 'claim', data: { _type: 'Bool' } }, maxAge: { type: 'constant', data: { _type: 'Field' }, value: '100' }, }, logic: { @@ -604,7 +605,7 @@ test('convertSpecToSerializable', async (t) => { inputs: { signedData: { type: 'credential', - id: 'signature-native', + credentialType: 'simple', witness: { type: { type: 'Constant', value: 'simple' }, issuer: { _type: 'PublicKey' }, @@ -673,13 +674,13 @@ test('convertSpecToSerializable', async (t) => { inputs: { field1: { type: 'credential', - id: 'none', + credentialType: 'unsigned', witness: { _type: 'Undefined' }, data: { _type: 'Field' }, }, field2: { type: 'credential', - id: 'none', + credentialType: 'unsigned', witness: { _type: 'Undefined' }, data: { _type: 'Field' }, }, @@ -771,7 +772,7 @@ test('Serialize and deserialize spec with hash', async (t) => { await t.test('should detect tampering', async () => { const tampered = JSON.parse(serialized); const tamperedSpec = JSON.parse(tampered.spec); - tamperedSpec.inputs.age.type = 'public'; + tamperedSpec.inputs.age.type = 'claim'; tampered.spec = JSON.stringify(tamperedSpec); const tamperedString = JSON.stringify(tampered); assert( @@ -785,7 +786,7 @@ test('Serialize and deserialize spec with hash', async (t) => { async () => { const tampered = JSON.parse(serialized); const tamperedSpec = JSON.parse(tampered.spec); - tamperedSpec.inputs.age.type = 'public'; + tamperedSpec.inputs.age.type = 'claim'; tampered.spec = JSON.stringify(tamperedSpec); const tamperedString = JSON.stringify(tampered); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 2bdd453..92123d9 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,7 +1,7 @@ import { Field, PrivateKey } from 'o1js'; import { type Credential, - type CredentialType, + type CredentialSpec, signCredentials, } from '../src/credential.ts'; @@ -21,7 +21,7 @@ const zkAppVerifierIdentity = PrivateKey.random().toPublicKey(); function createOwnerSignature( context: Field, ...credentials: [ - CredentialType, + CredentialSpec, { credential: Credential; witness: Witness } ][] ) { diff --git a/tsconfig.json b/tsconfig.json index 5d23564..85ae632 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["./src", "./tests"], + "include": ["./src", "./tests", "./examples"], "compilerOptions": { "outDir": "./build", "rootDir": ".",