Liccium Developer Portal
Authentication

Metadata Signature Creation

To ensure the declaration is verifiable, all metadata must be signed using a private key, which generates a cryptographic signature proving its authenticity. Liccium supports two approaches for signing: X.509 Certificate-based signing and Keypair-based signing with embedded credentials.

Overview

Metadata signatures in Liccium serve multiple purposes:

  • Authentication: Proves the identity of the declarer
  • Integrity: Ensures the metadata hasn't been tampered with
  • Non-repudiation: Provides proof that the declaration was made by the certificate holder

Two Signing Approaches

Approach 1: X.509 Certificate

  • ✓ Uses X.509 certificate chain
  • ✓ Certificate embedded in JWT header (x5c)
  • ✓ Requires certificate authority validation
  • ✓ Best for enterprise/organizational use

Approach 2: Generated Keypair

  • ✓ Uses self-generated EC keypair
  • ✓ Public key embedded as JWK in JWT header
  • ✓ Credentials included in publicMetadata
  • ✓ Best for flexible, self-managed identities

Prerequisites

Before creating signatures, ensure you have:

Required Components

  • ✓ EC (P-256) private key
  • ✓ Node.js environment
  • ✓ Structured public metadata
  • ✓ Verifiable credentials (for keypair approach)

Setup Requirements

  • ✓ JWT signing library (jsonwebtoken)
  • ✓ Crypto module for key operations
  • ✓ Valid declarerId (DID format)
  • ✓ Complete metadata structure

Approach 1: X.509 Certificate-Based Signing

This approach uses X.509 certificates to establish identity. The certificate chain is embedded in the JWT header using the x5c field.

Step 1: Prepare Your Public Metadata

Structure your public metadata according to Liccium requirements:

JavascriptCode
const publicMetadata = { "$schema": "https://w3id.org/liccium/schema/0.1.0.json", "@context": "https://w3id.org/liccium/context/0.1.0.json", iscc: "ISCC:KACYPXW445FNGZZ2", // Optional: 256-bit Content-Code ISCC-UNIT of the same asset. // Must start with the 64-bit Content-Code unit embedded in `iscc`. // isccContentCode256: "ISCC:EEDY2FY2RKC3V5R4BMYFK6U7YTUQ4M2YPWRMP3ARGZNYBJOK54KDSXQ", name: "Example Content Title", description: "Detailed description of the content", mediatype: "image/jpeg", timestamp: Date.now(), declarerId: "did:web:yourdomain.com", credentials: [ { "@context": ["https://www.w3.org/ns/credentials/v2"], type: ["VerifiableCredential", "VerifiableAttestation"], proof: { type: "JwtProof2020", jwt: "eyJhbGciOiJSUzI1NiJ9..." } } ] };

Metadata Requirements: Ensure your metadata includes all required fields: $schema, @context, iscc, name, timestamp, declarerId, and credentials. Missing required fields will cause signature validation to fail.

Step 2: Load Your Certificate

Load your X.509 certificate and private key:

JavascriptCode
const fs = require('fs'); const jwt = require('jsonwebtoken'); const certificatePem = fs.readFileSync('path/to/your/cert.pem', 'utf8'); const privateKeyPem = fs.readFileSync('path/to/your/private-key.pem', 'utf8'); const certBase64 = certificatePem .replace(/-----BEGIN CERTIFICATE-----/g, '') .replace(/-----END CERTIFICATE-----/g, '') .replace(/\n/g, '') .trim();

Step 3: Create the JWT Signature

Generate a JWT signature using your certificate and private key:

JavascriptCode
const signature = jwt.sign(publicMetadata, privateKeyPem, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', x5c: [certBase64], } });

Step 4: Complete Certificate-Based Example

Full example with validation:

JavascriptCode
const fs = require('fs'); const jwt = require('jsonwebtoken'); class CertificateSignatureCreator { constructor(certPath, privateKeyPath) { const certPem = fs.readFileSync(certPath, 'utf8'); this.privateKey = fs.readFileSync(privateKeyPath, 'utf8'); this.certBase64 = certPem .replace(/-----BEGIN CERTIFICATE-----/g, '') .replace(/-----END CERTIFICATE-----/g, '') .replace(/\n/g, '') .trim(); } signMetadata(publicMetadata) { this.validateMetadata(publicMetadata); return jwt.sign(publicMetadata, this.privateKey, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', x5c: [this.certBase64] } }); } validateMetadata(metadata) { const requiredFields = ['$schema', '@context', 'iscc', 'name', 'timestamp', 'declarerId', 'credentials']; for (const field of requiredFields) { if (!metadata[field]) throw new Error(`Missing required field: ${field}`); } if (!Array.isArray(metadata.credentials) || metadata.credentials.length === 0) { throw new Error('credentials must be a non-empty array'); } } } const signer = new CertificateSignatureCreator('path/to/cert.pem', 'path/to/private-key.pem'); const signature = signer.signMetadata(publicMetadata);

Approach 2: Keypair-Based Signing with Embedded JWK

This approach uses a self-generated EC keypair. The public key is embedded in the JWT header as a JSON Web Key (JWK), and verifiable credentials are included in the publicMetadata.

Step 1: Generate an EC Keypair

TerminalCode
openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem openssl ec -in private_key.pem -pubout -out public_key.pem

Step 2: Convert Public Key to JWK

JavascriptCode
const fs = require('fs'); const crypto = require('crypto'); const publicKeyPem = fs.readFileSync('public_key.pem', 'utf8'); const keyObject = crypto.createPublicKey(publicKeyPem); const jwk = keyObject.export({ format: 'jwk' });

Step 3: Create JWT Signature with JWK in Header

JavascriptCode
const signature = jwt.sign(publicMetadata, privateKeyPem, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', jwk: jwk, } });

Step 5: Complete Keypair-Based Example

JavascriptCode
const fs = require('fs'); const jwt = require('jsonwebtoken'); const crypto = require('crypto'); class KeypairSignatureCreator { constructor(privateKeyPath, publicKeyPath) { this.privateKey = fs.readFileSync(privateKeyPath, 'utf8'); const publicKeyPem = fs.readFileSync(publicKeyPath, 'utf8'); const keyObject = crypto.createPublicKey(publicKeyPem); this.jwk = keyObject.export({ format: 'jwk' }); } signMetadata(publicMetadata) { this.validateMetadata(publicMetadata); return jwt.sign(publicMetadata, this.privateKey, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', jwk: this.jwk } }); } validateMetadata(metadata) { const requiredFields = ['$schema', '@context', 'iscc', 'name', 'timestamp', 'declarerId', 'credentials']; for (const field of requiredFields) { if (!metadata[field]) throw new Error(`Missing required field: ${field}`); } if (!Array.isArray(metadata.credentials) || metadata.credentials.length === 0) { throw new Error('credentials must be a non-empty array'); } } } const signer = new KeypairSignatureCreator('private_key.pem', 'public_key.pem'); const signature = signer.signMetadata(publicMetadata);

Signature Verification

Decode the JWT (without verification) to inspect structure:

JavascriptCode
const decoded = jwt.decode(signature, { complete: true }); console.log('JWT Header:', JSON.stringify(decoded.header, null, 2));

Certificate-based (x5c): header contains x5c with base64-encoded certificate.
Keypair-based (jwk): header contains jwk with EC P-256 public key.


Security Best Practices

Security Considerations:

  • Never log or expose private keys
  • Use secure key storage in production (HSM, key vaults)
  • Implement proper error handling for signing failures
  • Validate all input data before signing
  • Store credentials securely and validate before inclusion

Key Management

  1. Secure Storage – Store private keys in HSM or key vaults
  2. Access Control – Limit access to private keys to authorized systems
  3. Key Rotation – Regularly rotate keys/certificates and update DID documents
  4. Monitoring – Monitor key usage and detect unauthorized signing

Common Issues and Solutions

IssueCauseSolution
Invalid signature formatIncorrect algorithm or headerUse ES256 and proper x5c or jwk header
Certificate/JWK not foundMissing x5c or jwk in JWT headerInclude base64 certificate or JWK in header
Validation failsMetadata missing required fieldsValidate structure before signing ($schema, @context, iscc, name, timestamp, declarerId, credentials)
Key mismatchPrivate key doesn't match certificate/public keyVerify key pair correspondence
Credentials validation failsMissing or invalid credentials arrayEnsure credentials is a non-empty array with valid VC structure

Integration with Liccium

Include your signature in the declaration payload:

JavascriptCode
const declarationPayload = { signature: signature, tsaSignature: { tsr: "base64-encoded-timestamp-response", tsq: "base64-encoded-timestamp-request" }, declarationMetadata: { publicMetadata: publicMetadata } };

For complete declaration examples, see the Declaration API.


Signing Registry Objects

Besides publicMetadata, a declaration can carry one registry object per registry it should be published to. Each registry object is signed on its own, with the same private key and approach chosen above, and carries its own TSA timestamp:

RegistryObjectSignatureTimestamp
TDM·AI registry (AI preferences)declarationMetadata.tdmaiRegistrytdmaiRegistrySignaturetdmaiRegistryTsaSignature
FAIA registry (AI attribution, faia.io)declarationMetadata.faiaRegistryfaiaRegistrySignaturefaiaRegistryTsaSignature

A declaration is a record of a registry only when it carries that registry's object. A faia block inside liccium_plugins or inside tdmaiRegistry describes the content, but does not publish the declaration to the FAIA registry. A declaration may carry both objects; it is then a record of both registries.

TDM·AI registry: tdmaiRegistry

If your declaration includes a tdmaiRegistry record, the same signing process must be applied to it independently, using the same private key and approach chosen above.

declarationMetadata.tdmaiRegistry is the TDM-AI registry record: an object with the required fields iscc, declarerId and timestamp (milliseconds, within 60 seconds of server time), plus one sub-object per active opt-out plugin — tdmrep ({"tdm-reservation": 0|1, "tdm-policy": "<url>"}), faia ({"faiaFlag", "activityCode", "systemAttribution", "systemVersion"}), and further plugins (ietf, c2pa, ...) keyed by plugin name. It is transmitted as this JSON object — a JSON-serialized string is not accepted. The JWT payload carries the same object (key order does not matter — the registry compares the records canonically).

The record requires two additional top-level fields in the declaration payload:

  • tdmaiRegistrySignature – an ES256 JWT (public JWK embedded in the header) whose payload is { "tdmaiRegistry": <the same record object> }
  • tdmaiRegistryTsaSignature – an RFC 3161 TSA timestamp (tsq/tsr, base64) over the tdmaiRegistrySignature JWT, produced identically to the main tsaSignature

Example

JavascriptCode
// The registry record — a plain object, transmitted and signed as-is. const tdmaiRegistry = { iscc: publicMetadata.iscc, declarerId: publicMetadata.declarerId, timestamp: publicMetadata.timestamp, tdmrep: { "tdm-reservation": 1, "tdm-policy": "https://example.com/ai-training-opt-out" }, faia: { faiaFlag: "aac", activityCode: "coCreation", systemAttribution: "ChatGPT", systemVersion: "GPT-4o" }, }; const tdmaiRegistrySignature = jwt.sign({ tdmaiRegistry }, privateKeyPem, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', jwk: jwk, // or x5c: [certBase64] for the certificate-based approach } }); // Then obtain a TSA timestamp over the tdmaiRegistrySignature JWT (see TSA Signature guide) // and include both in the declaration: const declarationPayload = { signature: signature, tsaSignature: { tsr: "base64-encoded-timestamp-response", tsq: "base64-encoded-timestamp-request" }, tdmaiRegistrySignature: tdmaiRegistrySignature, tdmaiRegistryTsaSignature: { tsr: "base64-encoded-tdmairegistry-timestamp-response", tsq: "base64-encoded-tdmairegistry-timestamp-request" }, declarationMetadata: { publicMetadata: publicMetadata, // sibling of publicMetadata — NOT nested inside it tdmaiRegistry: tdmaiRegistry } };

The tdmaiRegistrySignature and tdmaiRegistryTsaSignature fields are only required when a tdmaiRegistry value is present in the declaration. If no registry payload is provided, these fields can be omitted entirely.

FAIA registry: faiaRegistry

declarationMetadata.faiaRegistry is the FAIA registry record: an object with iscc, declarerId and timestamp — the same values as in publicMetadata (the timestamp within 60 seconds of publicMetadata.timestamp) — plus the faia statement (faiaFlag, and optionally activityCode, systemAttribution, systemVersion; see the FAIA Plugin guide). It is transmitted as this JSON object — a JSON-serialized string is not accepted.

The record requires two additional top-level fields in the declaration payload:

  • faiaRegistrySignature – an ES256 JWT (public JWK embedded in the header) whose payload is { "faiaRegistry": <the same record object> }, signed with the same key as signature
  • faiaRegistryTsaSignature – an RFC 3161 TSA timestamp (tsq/tsr, base64) over the faiaRegistrySignature JWT

The FAIA registry verifies all of this before publishing. A declaration whose faiaRegistry fails any of these checks is stored by Liccium but never appears in the FAIA registry.

Example

JavascriptCode
const faiaRegistry = { iscc: publicMetadata.iscc, declarerId: publicMetadata.declarerId, timestamp: publicMetadata.timestamp, faia: { faiaFlag: "aac", activityCode: "coCreation", systemAttribution: "ChatGPT", systemVersion: "GPT-4o" }, }; const faiaRegistrySignature = jwt.sign({ faiaRegistry }, privateKeyPem, { algorithm: 'ES256', header: { typ: 'JWT', alg: 'ES256', jwk: jwk }, // the same key as `signature` }); // Then obtain a TSA timestamp over the faiaRegistrySignature JWT and include everything: const declarationPayload = { signature, tsaSignature, tdmaiRegistrySignature, // when the declaration also goes to the TDM·AI registry tdmaiRegistryTsaSignature, faiaRegistrySignature, faiaRegistryTsaSignature: { tsr: "base64-encoded-faiaregistry-timestamp-response", tsq: "base64-encoded-faiaregistry-timestamp-request" }, declarationMetadata: { publicMetadata, tdmaiRegistry, // optional: TDM·AI registry record faiaRegistry // sibling of publicMetadata — NOT nested inside it } };

faiaRegistrySignature and faiaRegistryTsaSignature are only required when faiaRegistry is present. The legacy faiaMetadata block is not accepted by the FAIA registry.


Next Steps

  1. TSA Signature Creation – Add timestamp signatures for temporal verification
  2. Test your signatures with the Declaration API
  3. Implement error handling for production use
  4. Monitor certificate/key expiration and plan for renewal
Last modified on