Summary
Divine mobile's C2PA creator-binding flow needs a sign_canonical JSON-RPC
method on the Keycast server so OAuth-signed-in users get a real inline
creator-binding assertion on every Divine post. Today the mobile-side
fallback at KeycastRpc.signCanonicalPayload returns null (no method on
the backend), so NostrCreatorBindingService.createAssertion skips the
binding and the C2PA proof manifest ships without it.
The mobile side is already wired and forward-compatible (mobile PR
divinevideo/divine-mobile#3683):
once this method ships, every Divine OAuth post gets the binding for free
with no further mobile change.
Method contract
| Field |
Value |
| Method name |
sign_canonical |
| Params |
[base64(payload)] — single base64-encoded byte string |
| Result |
hex-encoded schnorr signature (String) |
| Error |
standard JSON-RPC error response on unsupported / failure |
Determinism contract (REQUIRED)
The signature must be deterministic for a given (privateKey, payload)
pair so repeated signing of the same payload produces the same signature —
this is what keeps creator-binding assertions stable across re-publishes,
re-uploads, and proof-manifest re-evaluations.
The mobile-side local signer at
mobile/lib/services/local_key_signer.dart:55-74
implements this as:
- Hash:
digest = SHA-256(payload) → 32-byte digest, hex-encoded as a
lowercase string.
- Sign: BIP-340 schnorr signature over the digest using the account's
private key.
- Auxiliary data: 32 zero bytes (constant
_canonicalPayloadAux = '0' * 64 in hex). BIP-340 randomized aux would
produce different signatures across calls; that is unacceptable for
creator-binding stability.
Pseudocode (Rust-ish — adjust to Keycast's stack):
fn sign_canonical(account_private_key: SecretKey, payload_b64: String) -> Result<String> {
let payload = base64::decode(payload_b64)?;
let digest: [u8; 32] = sha256(&payload);
let aux: [u8; 32] = [0; 32]; // MUST be all zeros for determinism
let sig = schnorr::sign(account_private_key, digest, aux);
Ok(hex::encode(sig))
}
Test vector
Computed against the Dart bip340 package (which the mobile side uses).
A backend implementation should self-verify by signing the same payload
with the same private key and asserting bit-equal output.
| Field |
Value |
payload (UTF-8) |
divine-creator-binding-test |
payload (hex) |
646976696e652d63726561746f722d62696e64696e672d74657374 |
payload (base64) |
ZGl2aW5lLWNyZWF0b3ItYmluZGluZy10ZXN0 |
privateKey |
5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12 |
digest (hex) |
39341a12a2f77007d6e72841f667523d39463a825d82c6c98981881283fb7ed0 |
aux (hex) |
0000000000000000000000000000000000000000000000000000000000000000 |
expected signature |
9baed2647e5f9d059b68eb03c6e3e6dcdf53cbe94fb143af70fb6e7332ee9997cc7ba5ac9cdb9049a0e47c8c20e2031843e88c59dcba3c3ff8fc34eeae4a565f |
If your BIP-340 implementation produces a different signature for these
inputs, the most likely cause is non-zero aux bytes (the default for many
schnorr libraries) — make sure the aux is forced to 32 zero bytes.
Mobile-side wiring (already shipped on the PR branch)
Permalinks pinned to commit
7c6c0f78a
(head of fix/publish-hang-disconnected-relay):
Acceptance
Source
Summary
Divine mobile's C2PA creator-binding flow needs a
sign_canonicalJSON-RPCmethod on the Keycast server so OAuth-signed-in users get a real inline
creator-binding assertion on every Divine post. Today the mobile-side
fallback at
KeycastRpc.signCanonicalPayloadreturnsnull(no method onthe backend), so
NostrCreatorBindingService.createAssertionskips thebinding and the C2PA proof manifest ships without it.
The mobile side is already wired and forward-compatible (mobile PR
divinevideo/divine-mobile#3683):
once this method ships, every Divine OAuth post gets the binding for free
with no further mobile change.
Method contract
sign_canonical[base64(payload)]— single base64-encoded byte stringString)Determinism contract (REQUIRED)
The signature must be deterministic for a given
(privateKey, payload)pair so repeated signing of the same payload produces the same signature —
this is what keeps creator-binding assertions stable across re-publishes,
re-uploads, and proof-manifest re-evaluations.
The mobile-side local signer at
mobile/lib/services/local_key_signer.dart:55-74implements this as:
digest = SHA-256(payload)→ 32-byte digest, hex-encoded as alowercase string.
private key.
_canonicalPayloadAux = '0' * 64in hex). BIP-340 randomized aux wouldproduce different signatures across calls; that is unacceptable for
creator-binding stability.
Pseudocode (Rust-ish — adjust to Keycast's stack):
Test vector
Computed against the Dart
bip340package (which the mobile side uses).A backend implementation should self-verify by signing the same payload
with the same private key and asserting bit-equal output.
payload(UTF-8)divine-creator-binding-testpayload(hex)646976696e652d63726561746f722d62696e64696e672d74657374payload(base64)ZGl2aW5lLWNyZWF0b3ItYmluZGluZy10ZXN0privateKey5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12digest(hex)39341a12a2f77007d6e72841f667523d39463a825d82c6c98981881283fb7ed0aux(hex)0000000000000000000000000000000000000000000000000000000000000000signature9baed2647e5f9d059b68eb03c6e3e6dcdf53cbe94fb143af70fb6e7332ee9997cc7ba5ac9cdb9049a0e47c8c20e2031843e88c59dcba3c3ff8fc34eeae4a565fIf your BIP-340 implementation produces a different signature for these
inputs, the most likely cause is non-zero aux bytes (the default for many
schnorr libraries) — make sure the aux is forced to 32 zero bytes.
Mobile-side wiring (already shipped on the PR branch)
Permalinks pinned to commit
7c6c0f78a(head of
fix/publish-hang-disconnected-relay):mobile/packages/keycast_flutter/lib/src/rpc/keycast_rpc.dart—
signCanonicalPayload(Uint8List payload)calls_call('sign_canonical', [base64Encode(payload)], (r) => r as String).Returns
nullonRpcException(today's path). No further mobile workneeded once this method ships.
mobile/lib/services/nostr_identity.dart—
KeycastNostrIdentity.signCanonicalPayloadfalls back toKeycastRpcwhen no local signer is available.
mobile/lib/services/nostr_creator_binding_service.dart—
createAssertionreturnsnull(skip binding) instead of throwingwhen canonical signing is unsupported.
Acceptance
sign_canonicalaccepts the param shape above and returns a hexschnorr signature.
(privateKey, payload)twice produces bit-equaloutput.
9baed2647e5f9d059b68eb03c6e3e6dcdf53cbe94fb143af70fb6e7332ee9997cc7ba5ac9cdb9049a0e47c8c20e2031843e88c59dcba3c3ff8fc34eeae4a565f.as standard JSON-RPC error responses; mobile already treats those
as "skip binding gracefully."
Source
mobile/lib/services/local_key_signer.dart:55-74