How to sign requests made with the HTTP API?

I want to create a mini app, that will use Key Value pairs stored on Ceramic and then change these. The idea behind was to test and get to know ceramic a bit more. I thought the HTTP API would be the easiest to use for this and I was able to create streams without content using this command:

curl http://localhost:7007/api/v0/streams -X POST -d ‘{
“type”: 0,
“genesis”: {
“header”: {
“family”: “test”,
“controllers”: [“did:key:z6MkfZ6S4NVVTEuts8o5xFzRMR8eC6Y1bngoBQNnXiCvhH8H”]
}
}
}’ -H “Content-Type: application/json”

When I add “data”:“xyz”, to the genesis content it requires a signature to be posted {‘error’: ‘Genesis commit with contents should always be signed’}.

However I cannot find out from the HTTP API docs how to sign my request. Could anyone help me out with this?

Easiest way to understand how this works right now is to have a look at the http-client source code:

Hi everyone! I’m also interested in this and have spent several hours today reviewing the client code since I would like to update streams (using python requests), but I’m not a JS developer. Could you maybe point us to a specific code section where the signature is performed or maybe some documentation where the process is outlined?

yes anything beyond simple reads from the http api require more language specific libraries to support the signing of the properly formatted payloads

There is one example of what it looks like in the docs, but is not covered on how to construct that

You would need a language specific library similar to dids js-did/packages/dids at main · ceramicnetwork/js-did · GitHub

You can see here and by reading the source code, how you would create this signed dagjws payload, mainly the did.createDagJWS function - js-did/packages/dids at main · ceramicnetwork/js-did · GitHub

mainly need crypto library to sign (ideally sign jws), then ideally dag-jose codec to encode

May need to split this thread, you both might have different needs, @mdot can you use js libraries or not? (suggested way given complexities above) and @davidv are you trying to create a python client?

1 Like

@zfer I’m trying to have a very simple python client to create and read streams and commits using key:did. It’s not a full-fledged client. I’m exploring the possibility of using the jwcrypto or python-jose libraries for signing, but I’m not 100% sure if that’ll do it.

Yes I will move on to the JS library because the using the HTTP API it is too complex for my use-case. I am still interested in the methodology of course, so I will follow davidv’s thread.

I’m still trying to figure out how to properly build and sign the commit payload, but I’ve found the following: when I send an empty payload, even if is not properly structure or signed, I still get a 200 code.
Of course, the commit is not applied but this gives me no feedback about what is wrong with my payload. In the other hand, if the payload is properly structured (for example if I’m using this one as example) I get a 500 error ({‘error’: ‘context deadline exceeded’}).
I guess this has to do with the fact that this payload is not signed by the correct signed, but I still miss some feedback about what is wrong with my payloads. Do you have any advice here?

I’m also a bit confused about the relationship between the commit structure here:

curl http://localhost:7007/api/v0/commits -X POST -d '{
  "streamId": "kjzl6cwe1jw14ahmwunhk9yjwawac12tb52j1uj3b9a57eohmhycec8778p3syv",
  "commit": {
    "jws": {
      "payload": "AXESINm6lI30m3j5H2ausx-ulXj-L9CmFlOTZBZvJ2O734Zt",
      "signatures": [
        {
          "signature": "zsLJbBSU5xZTQkYlXwEH9xj_t_8frvSFCYs0SlVMPXOnw8zOJOsKnJDQlUOvPJxjt8Bdc_7xoBdmcRG1J1tpCw",
          "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2ZaNlM0TlZWVEV1dHM4bzV4RnpSTVI4ZUM2WTFibmdvQlFOblhpQ3ZoSDhII3o2TWtmWjZTNE5WVlRFdXRzOG81eEZ6Uk1SOGVDNlkxYm5nb0JRTm5YaUN2aEg4SCJ9"
        }
      ],
      "link": "bafyreigzxkki35e3pd4r6zvowmp25fly7yx5bjqwkojwiftpe5r3xx4gnu"
    },
    "linkedBlock": "pGJpZNgqWCYAAYUBEiDRQJ7VCtGQWcLlmFpitGoSP35ntX7fKJeFWJ8zKz2+Z2RkYXRhgaNib3BjYWRkZHBhdGhlL21vcmVldmFsdWUY6mRwcmV22CpYJgABhQESINFAntUK0ZBZwuWYWmK0ahI/fme1ft8ol4VYnzMrPb5nZmhlYWRlcqFrY29udHJvbGxlcnOA"
  }
}' -H "Content-Type: application/json"

and the one outlined in the spec:

Signed commits contain a pointer to the previous commit as prev , an update to the stream as data , and the encoded signature.

type SignedCommit struct {
  id Link
  prev Link
  header optional SignedHeader
  data Any
}

Is the latter structure the one before the payload is encoded? That’s what I understand from the code. Could someone give me a list of high level steps I need to follow in order to get this right?

Just in case it helps, this is my current attempt to create a stream (which seems to work), retrieve data from it (also seems to work) and update it (here’s where the structure building and signing is problematic):

import requests
import jsonpatch
from jose import jws, jwt
from base64 import urlsafe_b64encode, urlsafe_b64decode
import numpy as np
import dag_cbor
from cid import make_cid
import hashlib
import json
import sys
from ipfs import IPFSHashOnly

# Glaze quick start
# https://developers.ceramic.network/build/cli/quick-start/#__tabbed_1_1
# ceramic daemon
# glaze did:create    ->    Created DID did:key:z6MkfdMoFrvCJgQ5EyAzTs6es1zpRiBTU5iK78T2ixJens7z with seed 2172441f1cc497e9aa3d9841f6b39f28f5b9ce216f96f7f5e03f5f7a8d481884
# glaze tile:create --key 2172441f1cc497e9aa3d9841f6b39f28f5b9ce216f96f7f5e03f5f7a8d481884 --content '{"Foo":"Bar"}'
# glaze tile:update kjzl6cwe1jw148hj4mdddncgjemxhuexbaea1vlmlecuvuxdllmacbgumu9vard --key 2172441f1cc497e9aa3d9841f6b39f28f5b9ce216f96f7f5e03f5f7a8d481884 --content '{"Foo":"Baz"}'
# glaze tile:show kjzl6cwe1jw148hj4mdddncgjemxhuexbaea1vlmlecuvuxdllmacbgumu9vard


class CeramicManager:
    # Docs:
    # https://developers.ceramic.network/build/http/api/#__tabbed_3_1

    # url_base = "https://gateway-clay.ceramic.network"  # gateway node (read only)
    url_base = "https://ceramic-clay.3boxlabs.com"  # read & write

    create_stream_endpoint = ""

    def __init__(self) -> None:
        pass

    def make_request(self, url: str, request_type: str="get", json_data: dict={}):
        if request_type not in ("get", "post", "delete"):
            return None, None
        if request_type == "get":
            response = requests.get(url)
        if request_type == "post":
            response = requests.post(url, json=json_data)
        if request_type == "delete":
            response = requests.delete(url)
        return response.status_code, response.json() if "application/json" in response.headers.get("content-type") else {}

    def create_stream(self, genesis: dict, options: dict={}, type: int=0):
        return self.make_request(f"{self.url_base}/api/v0/streams", "post", json_data=dict(genesis=genesis, opts=options, type=type))

    def get_stream(self, streamid: str):
        return self.make_request(f"{self.url_base}/api/v0/streams/{streamid}", "get")

    def pin_stream(self, streamid: str):
        return self.make_request(f"{self.url_base}/api/v0/pins/{streamid}", "post")

    def unpin_stream(self, streamid: str):
        return self.make_request(f"{self.url_base}/api/v0/pins/{streamid}", "delete")

    def get_commits(self, streamid: str):
        return self.make_request(f"{self.url_base}/api/v0/commits/{streamid}", "get")

    def add_commit(self, streamid: str, commit: dict, options: dict={}):
        return self.make_request(f"{self.url_base}/api/v0/commits", "post", json_data=dict(streamId=streamid, commit=commit, opts=options))


# Generate a key:did (for simplicity) with Glaze. We do not need encryption.
did = "did:key:z6MkfdMoFrvCJgQ5EyAzTs6es1zpRiBTU5iK78T2ixJens7z"
did_seed = "172441f1cc497e9aa3d9841f6b39f28f5b9ce216f96f7f5e03f5f7a8d481884"

# Instantiate the manager
cman = CeramicManager()

# Prepare the genesis commit: no need to sign if there's no data on it
genesis_data = {
    "header": {
        "family": "test",
        "controllers": [did],  #  who is allowed to make updates
    },
}

# Create a new stream
code, data = cman.create_stream(genesis=genesis_data)
print("Create stream", code)
print(data)

streamid = data["streamId"]
genesis_cid = data["state"]["log"][0]["cid"]

# Get a stream
# cman.get_stream(streamid)

# Get the latest commit
code, data = cman.get_commits(streamid)
print("Get commits", code)
print(data)
latest_cid = data["commits"][-1]["cid"]

# Prepare a data diff
old_data = {}
new_data = {"my_data_key": "my_data_value"}
patch = jsonpatch.make_patch(old_data, new_data)

# https://github.com/ceramicnetwork/ceramic/blob/main/SPECIFICATION.md#signed-commits
commit_struct =  {
    "header": {
        "family": "test",
        "controllers": [did],  #  who is allowed to make updates
    },
    "data": patch.to_string(),
    "prev": latest_cid,
    "id": genesis_cid,
}

# Encode payload
encoded_bytes = dag_cbor.encode(commit_struct)
print("encoded", encoded_bytes)
hashed = hashlib.sha256(encoded_bytes).hexdigest()
print("hashed", hashed)

# Get commit_struct CID
cid = IPFSHashOnly.hash_bytes(  # using IPFSHashOnly from open-aea
    data=bytes(hashed,"utf-8"),
    wrap = False,
    cid_v1 = True
)  # TODO: this could be what comes from Block.encode, but they also use the codec.code
   # so our CID is probably wrong
print("cid", cid)

# Build the payload for signing
payload = {
    "did": did,
    "payload": cid,
    "linkedBlock": encoded_bytes.hex(),  # TODO: should we use hex here?
}

# Sign
signed = jws.sign(payload, did_seed, algorithm='HS256')  # TODO: can we use here the seed from the DID generation like this?
print("signed", signed)  # this looks like eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhIjoiYiJ9.jiMyrsmD8AoHWeQgmxZ5yq8z0lXS67_QGs52AzC8Ru8
                         # It's similar to the "protected" field, but not to the "signature" one

# Build commit from the previous data # TODO
# For now, this is a copy of the working example from the docs
commit_data={
    "jws": {
        "payload": "AXESINm6lI30m3j5H2ausx-ulXj-L9CmFlOTZBZvJ2O734Zt",  # is this the signed payload?
        "signatures": [
            {
                "signature": "zsLJbBSU5xZTQkYlXwEH9xj_t_8frvSFCYs0SlVMPXOnw8zOJOsKnJDQlUOvPJxjt8Bdc_7xoBdmcRG1J1tpCw",
                "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2ZaNlM0TlZWVEV1dHM4bzV4RnpSTVI4ZUM2WTFibmdvQlFOblhpQ3ZoSDhII3o2TWtmWjZTNE5WVlRFdXRzOG81eEZ6Uk1SOGVDNlkxYm5nb0JRTm5YaUN2aEg4SCJ9"
            }
        ],
        "link": cid,
    },
    "linkedBlock": encoded_bytes.hex()  # TODO: should we use hex here?
}

# Apply commit
code, data = cman.add_commit(streamid=streamid, commit=commit_data)
print(code)
print(data)
# I see here a 200 even for empty payloads. With correctly structured payloads, I get a 500: {'error': 'context deadline exceeded'}

# Get the latest commit and check it matches the changes
code, data = cman.get_commits(streamid)
print(code)
print(data["commits"][-1])

PS: is there any non-CLI JS example that I can follow step by step to be sure that every value matches?

A better representation of the types is as follows, and these are ipld types.

type Event InitEvent | DataEvent | TimeEvent

//defined by jws 
type Signature struct {
  header optional { String : Any }
  // The base64url encoded protected header, contains:
  // `kid` - the DID URL used to sign the JWS
  // `cap` - IPFS url of the CACAO used (optional)
  protected optional String
  signature String
}

type DataHeader struct {
  controllers optional [String]
}

type DataEventPayload struct {
  id &InitEvent
  prev &Event
  header optional DataHeader
  data { String : Any }
}

//defined by dag jose codec 
type DataEvent struct { // This is a DagJWS
  payload String
  signatures [Signature]
  link: &DataEventPayload
}

The event/payload includes the data/prev/header. It is then wrapped in dagjws which you see posted. For the dagjws the payload is a base64url encoded IPLD CID that references the JSON object event/payload. The link is a CID (ipld link)

Printed output/serialization here and in example could be different than what is actually sent. I can look more closely, but for the quickest and most direct feedback to iterate on, it may make sense to run something like this demo/example

And look at the http requests in browser.

The http api alone wont give much more feedback at the moment, it is a mid/long term goal to write a http api spec, and better support multiple language implementations. More descriptive errors would likely be part of supporting that, for now they have been developed closely together (ceramic node and js client) and js client has been the only client for now.

1 Like

Hey @zfer , and thanks for the help. I’m now trying to run the Create Ceramic App but found the following problemas:

When running with ComposeDB, it fails with no error

david@XPS13:~/Descargas/ceramic/project2/my_app$ npm run dev

> demo@0.1.0 dev
> node scripts/run.mjs

⠹ [Ceramic] Starting Ceramic node
[Ceramic] 
> demo@0.1.0 ceramic
> CERAMIC_ENABLE_EXPERIMENTAL_COMPOSE_DB='true' npx ceramic daemon --config ./composedb.config.json

✖ [Ceramic] Ceramic node failed to start with error:
✖ [Ceramic] Ceramic daemon failed to start up:

When trying DID-DataStore, I get a message like this one:
Error: lock /home/david/.goipfs/repo.lock: someone else has the lock
but I’m able to access the frontend at localhost:3000, although when logging in I get an error:

Error: HTTP request to ['http://localhost:7007/api/v0/commits'](http://localhost:3000/'http://localhost:7007/api/v0/commits') failed with status 'Internal Server Error': {"error":"Can not verify signature for commit bagcqceraawxwfzuxf3ohqs5ht4hduzrv7sgmdh3bn2ew764usg5xwwyxpa6q: Signature does not belong to issuer"}

The full console output:

david@XPS13:~/Descargas/ceramic/project/my_app$ npm run dev

> demo@0.1.0 dev
> npx ceramic daemon --network inmemory & next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 556 ms (551 modules)
Ceramic daemon failed to start up:
Error: Initializing daemon...
Kubo version: 0.15.0
Repo version: 12
System version: amd64/linux
Golang version: go1.18.5
 

 Error: lock /home/david/.goipfs/repo.lock: someone else has the lock 

 Command failed with exit code 1: /usr/lib/node_modules/@ceramicnetwork/cli/node_modules/go-ipfs/go-ipfs/ipfs daemon
Error: lock /home/david/.goipfs/repo.lock: someone else has the lock
Initializing daemon...
Kubo version: 0.15.0
Repo version: 12
System version: amd64/linux
Golang version: go1.18.5
 


    at makeError (/usr/lib/node_modules/@ceramicnetwork/cli/node_modules/execa/lib/error.js:60:11)
    at handlePromise (/usr/lib/node_modules/@ceramicnetwork/cli/node_modules/execa/index.js:118:26)
    at processTicksAndRejections (node:internal/process/task_queues:96:5) {
  shortMessage: 'Command failed with exit code 1: /usr/lib/node_modules/@ceramicnetwork/cli/node_modules/go-ipfs/go-ipfs/ipfs daemon',
  command: '/usr/lib/node_modules/@ceramicnetwork/cli/node_modules/go-ipfs/go-ipfs/ipfs daemon',
  escapedCommand: '"/usr/lib/node_modules/@ceramicnetwork/cli/node_modules/go-ipfs/go-ipfs/ipfs" daemon',
  exitCode: 1,
  signal: undefined,
  signalDescription: undefined,
  stdout: 'Initializing daemon...\n' +
    'Kubo version: 0.15.0\n' +
    'Repo version: 12\n' +
    'System version: amd64/linux\n' +
    'Golang version: go1.18.5\n',
  stderr: 'Error: lock /home/david/.goipfs/repo.lock: someone else has the lock',
  failed: true,
  timedOut: false,
  isCanceled: false,
  killed: false
}
wait  - compiling / (client and server)...
event - compiled client and server successfully in 387 ms (846 modules)

I also had problems when connecting my Metamask to the web playground. It won’t connect (SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data).

Maybe it would be simpler if I could run and debug an example where a stream is created and updated using the JS library. Something like this script from your docs, but with authentication.

While trying to make this DID authentication thing work, I found this code from the key-did-provider-ed25519 repo’s README, but I’m having errors even with the (almost) unmodified code:

import { Ed25519Provider } from 'key-did-provider-ed25519'
import KeyResolver from 'key-did-resolver'
import { DID } from 'dids'
import { randomBytes } from '@stablelib/random'

const seed = randomBytes(32)  // 32 bytes with high entropy, got this from the tests
const provider = new Ed25519Provider(seed)
const did = new DID({ provider, resolver: KeyResolver.getResolver() })
await did.authenticate()

I get a throw new Error('No provider available'); error. Same thing if I try with the equivalent code from the key-did-provider-secp256k1 repo (this repo is now archived, has it been discontinued?). What is wrong here?

UPDATE:
So part of the problem is that this line needs to be:
const did = new DID({ provider: provider, resolver: KeyResolver.getResolver() })

Now, the next error happens during JWS verification:

// create JWS
const { jws, linkedBlock } = await did.createDagJWS({ hello: 'world' })

// verify JWS
await did.verifyJWS(jws)  // <- TypeError: Cannot read properties of undefined (reading 'signatures')`

UPDATE 2:
Also managed to solve that one. Maybe the README is outdated. This seems to work for me, but the payload from verify_result shows undefined (not sure if that’s expected):

// create JWS
const create_result = await ed2_did.createDagJWS({ hello: 'world' })

// verify JWS
const verify_result = await ed2_did.verifyJWS(create_result.jws)

The key-did-provider-ed25519 errors look maybe related to your version of js/node, otherwise those would be supported as written. And yes, a valid result returns no result, an invalid result would throw and error.

For the example and lock error, that can happen if you already running an ipfs process on your computer ( you would have to stop that), or if the ipfs process was abruptly stopped and not cleaned up. You try manually deleting the lock file.

I will also try it and take a closer look at the http requests again (has been some time), but will definitely get better feedback cycles if you can run it still.

1 Like

@zfer I’ve now made some progress and managed to produce valid payload data, but the signature is still wrong even I’ve tried to reproduce the all the steps from the JS implementation in python. Maybe there some parameter I’m missing. Is there anyone that could give me some advice re signing? My guess is that the problem lies within the sign_ed25519 function. Am I correct to assume that the signed payload is similar to this?

    payload_for_signing = {
        "did": did_str,
        "payload": payload_cid.decode("utf-8"),
        "linkedBlock": b64encode(encoded_bytes).decode('utf-8'),
    }

FTR, this is the full example:

import dag_cbor
import hashlib
import json
from base64 import urlsafe_b64encode, b64encode
from jwcrypto import jwk, jws
from jwcrypto.common import json_encode
from multiformats import CID
from jwcrypto.common import json_encode, base64url_encode
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
    Ed25519PrivateKey
)
from cryptography.hazmat.primitives import serialization

DAG_CBOR_CODEC_CODE = 113
SHA2_256_CODE = 18


def encode_cid(multihash: bytearray, cid_version:int=1, code:int=DAG_CBOR_CODEC_CODE) -> bytearray:
    """CID encoding"""                                          # JavasCript
    code_offset = 1                                             # varint.encodingLength(cid_version)
    hash_offset = 2                                             # codeOffset + varint.encodingLength(code)
    _bytes = bytearray([0] * (hash_offset + len(multihash)))    # new Uint8Array(hashOffset + multihash.byteLength)
    _bytes.insert(0, cid_version)                               # varint.encodeTo(version, bytes, 0)
    _bytes.insert(code_offset, code)                            # varint.encodeTo(code, bytes, codeOffset)
    _bytes[hash_offset:] = multihash                            # bytes.set(multihash, hashOffset)
    return _bytes


def create_digest(digest: bytearray, code:int=SHA2_256_CODE) -> bytearray:
    """Create a digest"""                             # JavasCript
    size = len(digest)
    size_offset = 1                                   # varint.encodingLength(code)
    digest_offset = 2                                 # sizeOffset + varint.encodingLength(size)
    _bytes = bytearray([0] * (digest_offset + size))
    _bytes.insert(0, code)                            # varint.encodeTo(code, bytes, 0)
    _bytes.insert(size_offset, size)                  # varint.encodeTo(size, bytes, sizeOffset)
    _bytes[digest_offset:] = digest                   # _bytes.set(digest, digestOffset)
    return _bytes


def base64UrlEncode(data):
    """"Base64 encoding"""
    return urlsafe_b64encode(data).rstrip(b"=")


def sign_ed25519(payload, seed):
    """Sign a payload using EdDSA (ed25519)"""

    # Create an ed25519 from the seed
    key_ed25519 = Ed25519PrivateKey.from_private_bytes(bytearray.fromhex(seed))

    # Derive the public and private keys
    # private key
    d = base64url_encode(
        key_ed25519.private_bytes(
            serialization.Encoding.Raw,
            serialization.PrivateFormat.Raw,
            serialization.NoEncryption()
        )
    )

    # public key
    x = base64url_encode(
        key_ed25519.public_key().public_bytes(
            serialization.Encoding.Raw,
            serialization.PublicFormat.Raw
        )
    )

    # Create a JWK key compatible with the jwcrypto library
    # https://jwcrypto.readthedocs.io/en/latest/jwk.html#classes
    # To create a random key: key = jwk.JWK.generate(kty='OKP', size=256, crv='Ed25519')
    key = jwk.JWK(
        **{
            "crv":"Ed25519",
            "d": d, # private key
            "kty":"OKP",
            "size":256,
            "x": x,  # public key
        }
    )

    # Create the JWS token from the payload
    jwstoken = jws.JWS(json_encode(payload))

    # Sign the payload
    # https://github.com/latchset/jwcrypto/blob/fcdc7d76b5a5924f9343a92b2627944a855ae62a/jwcrypto/jws.py#L477
    jwstoken.add_signature(
        key=key,
        alg=None,
        protected=json_encode({"alg": "EdDSA"}),  # the algorithm can be specified here or in the alg param, but results are different
        header=json_encode({"kid": key.thumbprint()})  # optional
    )

    signature_data = jwstoken.serialize()
    return signature_data


def create_JWS(payload: dict, did_seed: str) -> dict:
    """Create the JSON Web Signature of a Ceramic update payload"""

    # This code replicates the following JS implementation:
    # https://github.com/ceramicnetwork/js-did/blob/101e27cd306aced9322ec01a920b987006625d51/packages/dids/src/did.ts#L301
    # https://github.com/ceramicnetwork/js-did/blob/101e27cd306aced9322ec01a920b987006625d51/packages/dids/src/did.ts#L265

    # Encode the payload using the DAG_CBOR codec
    encoded_bytes = dag_cbor.encode(payload)

    # SHA256 hash
    hashed = create_digest( bytearray.fromhex(hashlib.sha256(encoded_bytes).hexdigest()) )

    # Create the hash CID
    cid = CID(base="base32", version=1, codec=DAG_CBOR_CODEC_CODE, digest=hashed)

    # Create the payload CID
    cid_bytes = encode_cid(hashed)
    payload_cid = base64UrlEncode(cid_bytes)

    # Build the payload for signing
    payload_for_signing = {
        "did": did_str,
        "payload": payload_cid.decode("utf-8"),
        "linkedBlock": b64encode(encoded_bytes).decode('utf-8'),
    }

    # Sign the payload using ed25519
    # https://github.com/ceramicnetwork/key-did-provider-ed25519/blob/60c78dce7df4d7231bc5280dfcb8d9c953d12a20/src/index.ts#L73
    # https://github.com/decentralized-identity/did-jwt/blob/4efd9a755738a8a6347611cbded042a133d0b91a/src/JWT.ts#L272
    signature_data = json.loads(sign_ed25519(payload_for_signing, did_seed))

    # Build the update payload
    update_payload = {
        "jws": {
          "payload": payload_cid.decode("utf-8"),
          "signatures": [
            {
                "protected": signature_data["protected"],
                "signature": signature_data["signature"]
            }
          ],
          "link": str(cid)
        },
        "linkedBlock": encoded_bytes.hex()
    }

    return update_payload


# Use the same seed and payload as in the JS example
did_seed = "0101010101010101010101010101010101010101010101010101010101010101"
did_str = "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX"
payload = {"hello": "world"}

jws = create_JWS(payload, did_seed)
print(jws["jws"]["signatures"])

# Expected result
# {
#   jws: {
#     payload: 'AXESIHhRlyKdyLsRUpRdpY4jSPfiee7e0GzCynNtDoeYWLUB',  # OK
#     signatures: [  # NOT OK
#       {
#         protected: 'eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa29uM05lY2Q2TmtreWZvR29IeGlkMnpuR2M1OUxVM0s3bXViYVJjRmJMZkxYI3o2TWtvbjNOZWNkNk5ra3lmb0dvSHhpZDJ6bkdjNTlMVTNLN211YmFSY0ZiTGZMWCJ9',
#         signature: 'pfC2-D8iobSVBZo_42NvEKoZyUSopvtLSC3SMfU-LWnps90b1rJ3qnDK6dlNf2DcEXTDHgRL7OuWTTC83yo-CA'
#       }
#     ],
#     link: CID(bafyreidykglsfhoixmivffc5uwhcgshx4j465xwqntbmu43nb2dzqwfvae)  # OK
#   },
#   linkedBlock: "a16568656c6c6f65776f726c64"  # OK
# }

What are you comparing the result to? getting http error or running js code?

they payload to be signed is just the encoded CID, did is for kid and linkedblock is just passed through, for signing details, looking at ed25519 signer might help if you havent yet - js-did/index.ts at 101e27cd306aced9322ec01a920b987006625d51 · ceramicnetwork/js-did · GitHub

@zfer Yes, I’ve been debugging that part and my payload (encoded CID) looks correct before signing now. I’ve now been focusing on the signing itself using crypto libraries written in Python (jwcrypto) but still can’t replicate the signature. I’ve seen that during the DID generation you use a 32-byte secret key, but during the signature 64 bytes are used. Is all this encoding and signing standard? One of our engineers suggested it might be not.

I had a conversation with @Justina yesterday and she mentioned that there is another grantee that have also used Python succesfully for exactly this. Would it be possible to access that code?

Update: we have figured out the encoding and signing, it was a matter of tweaking the signing library to match Ceramic’s library behaviour. Now, when we send a correctly signed payload we still get a 500 error, so I think we might be still missing something from the payload.

By looking at the spec:

type SignedHeader struct {
  controllers optional [String]
  schema optional String
  family optional String
  tags optional [String]
}

type SignedCommit struct {
  id Link
  prev Link
  header optional SignedHeader
  data Any
}

I’ve noticed that although the header is optional, if I don’t send one I get a “Unexpected end of data” error. When I include a header, I get “context deadline exceeded”. Can I confirm that the data field here is a json diff (from old data to new data)?

This would be easier to debug if I had a working JS example. From your docs, I’ve built the following one but it still raises a time out when I try to create a TileDocument. Could you help me understand what I’ve done wrong?

import { CeramicClient } from '@ceramicnetwork/http-client'
import { TileDocument } from '@ceramicnetwork/stream-tile'
import { Ed25519Provider } from 'key-did-provider-ed25519'
import KeyResolver from 'key-did-resolver'
import { DID } from 'dids'

// Create a seed
const seed = new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) //  32 bytes

// Create the did
const provider = new Ed25519Provider(seed)
const did = new DID({ provider: provider, resolver: KeyResolver.getResolver() })
await did.authenticate()

// Get the ceramic instance
const API_URL = "https://ceramic-clay.3boxlabs.com"
const client = new CeramicClient(API_URL)

// Create document example
const doc = await TileDocument.create(
    client,           // ceramic
    {hello: 'world'}, // content
    undefined,        // metadata?
    {asDID: did}      // opts
) // <----- {"error":"Request timed out"}

console.log(doc.content)
const streamId = doc.id.toString()

// Query stream
// const doc = await TileDocument.load(ceramic, streamId)
console.log(doc.content)

// Update stream
await doc.update({foo: 'baz'}, {tags: ['baz']})

// Query again
console.log(doc.content)

great, how did you determine the signature was correct now?

For genesis events (ie creating a stream/document), a header is required and must include an array with a single controller. All following events (data events) do not require a header. Yeah data/payload is a json diff.

I can try the js example and get back to you, have more time this afternoon to look at this.

To determine if the signature is correct I used a minimal example where I create and sign a simple payload (without sending to the API). Once I had that working in JS, then I debugged and compared to my Python code until different payloads produced the same signatures in both JS and Python.

Now I’ve updated the JS example to actually send the payload to the API, and after including the header it works. I’m now going to debug it as well and compare to the Python code and see what differs. After this is sorted, I should be able to correctly communicate with the API. This is the working JS example:

import { CeramicClient } from '@ceramicnetwork/http-client'
import { TileDocument } from '@ceramicnetwork/stream-tile'
import { Ed25519Provider } from 'key-did-provider-ed25519'
import KeyResolver from 'key-did-resolver'
import { DID } from 'dids'

// Create a seed
const did_str = "did:key:z6Mkon3Necd6NkkyfoGoHxid2znGc59LU3K7mubaRcFbLfLX"
const seed = new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) //  32 bytes

// Create the did
const provider = new Ed25519Provider(seed)
const did = new DID({ provider: provider, resolver: KeyResolver.getResolver() })
await did.authenticate()

// Get the ceramic instance
const API_URL = "https://ceramic-clay.3boxlabs.com"
const client = new CeramicClient(API_URL)

// Create the genesis data
const genesis_data = {
    header: {
        controllers: [did_str],
    },
    data: {
        hello: 'world'
    }
}

const genesis = {genesis: genesis_data, opts: {}, type: 1}

// Create document example
const doc = await TileDocument.create(
    client,           // ceramic
    genesis,          // content
    undefined,        // metadata?
    {asDID: did}      // opts
)

console.log(doc.content)
const streamId = doc.id.toString()
console.log(streamId)

// Query stream
// const doc = await TileDocument.load(ceramic, streamId)
// console.log(doc.content)

// Update stream
await doc.update(
    {foo: 'baz'},    // content
    undefined,       // metadata
    {asDID: did}     // opts
)

// Query again
console.log(doc.content)

@zfer could I have your input in the following observations?

  • When sending a request to the API from the Python side, I get a 500 error: {'error': 'context deadline exceeded'}. Is this a timeout?
  • The encoding and signing, as I said before, seems to produce the same results for a given payload for Python code and JS code. This leads me to believe that the commit is not properly formed before encoding and signing.

This is what the commit looks like in Python before the encoding. Do you think it looks ok? (I do not expect everything to match here as these are different streams).

Python
------
commit = {
    'data': [
        {'op': 'remove', 'path': '/genesis'},
        {'op': 'remove', 'path': '/type'},
        {'op': 'remove', 'path': '/opts'},
        {'op': 'add', 'path': '/foo', 'value': 'baz'}
    ],
    'prev': 'bafyreihkfookkdfmm5xyypicanr3xy7253bllwguvcq33fk37py3lkleiu',
    'id': 'bafyreigzf4sub5zvsyuuff3kepapa2oj7bqval2uwtrp34dfnugwcqcupq'
}

JS
---
data = equal to Python
prev = bagcqceraevyb3uowhk452ktxleefjh3pdv4tdjzra4ebplojq2yaerlusucq
id = bagcqceraevyb3uowhk452ktxleefjh3pdv4tdjzra4ebplojq2yaerlusucq

Two observations:

  • According to the specification, prev and id are IPLD links. I’m using what I get from the API by retrieving the last and first commits, but the JS example seems to use a different CID format (not bafyrei…). Is this correct?

  • In the JS example, both prev and id match (the stream only contains 1 commit), but when I create a stream calling the API from Python 2 commits are added automatically, so prev and id do not match. The genesis data looks like this:

genesis_data = {
    "genesis": {
        "header": {
            "controllers": [did]
        },
    },
    "type": 0,  # TileDocument
    "opts": {}
}