| Internet-Draft | KSG-Core | October 2026 |
| Nam | Expires 14 April 2027 | [Page] |
This document specifies the core of Keysingate: the canonical form and signature envelope of its documents, the time format and the rule that gives a signed object an upper time bound from public-network anchors, and the documents of the issuance channel -- release, delegation, block allocation, block closure, agent binding, container initiation, key inclusion, class promotion -- together with the status record that tracks a container's life. It is written so that two independent implementations produce the same bytes and reach the same verdict. The work journal kept inside a container, and its export, are specified in a companion document.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 14 April 2027.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document. Code Components extracted from this document must include Revised BSD License text as described in Section 4.e of the Trust Legal Provisions and are provided without warranty as described in the Revised BSD License.¶
The work journal and its export are specified in [KSG-JOURNAL].¶
An issuer hands out ranges of serial numbers; distributors pass parts of them down a channel; a holder turns a serial into a container by binding an agent's key and an artifact to it at one moment. Every step is a signed document, and a third party verifies the whole chain -- from the issuer's release to the container's identifier -- without contacting the issuer and without trusting any operator's word about time.¶
This document fixes what such verification needs to agree on: bytes, time, anchors, channel documents and status records. It does not specify how a container is built as a program, how keys are split or stored, which networks anchor, how they are read, or what the content of a journal page means.¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
Emission).¶
BlockAllocation).¶
offset_ms); not a date.¶
Signed structures are JSON canonicalized with JCS [RFC8785]. A signature is
computed over the UTF-8 bytes of the canonical form of the document without
its signatures member. An absent optional member is omitted; null and
absence are different documents.¶
Every number in a signed document MUST be an integer in 0 ... 2^53 - 1. A
document containing a fraction, a negative number or a larger integer MUST be
rejected before any signature is checked: only such numbers serialize to the
same bytes in every JCS implementation.¶
Every signed document of this core MUST carry
"@context": "urn:keysingate:core:v3", "type" naming the document and
"v": 3. A member not defined for the document type MUST cause rejection:
a document two implementations would read differently is accepted by
neither.¶
hash(doc) = SHA-256(JCS(doc without signatures)), written as a multihash:
the string 1220 followed by 64 lowercase hexadecimal digits. Uppercase
digits MUST be rejected.¶
signatures is an array of objects {kid, alg, value}; value is base64url
without padding [RFC4648].¶
alg
|
Use |
|---|---|
Ed25519
|
required; strict verification [RFC8032]: non-canonical encodings and small-order keys are rejected |
ML-DSA-65
|
optional [FIPS204]; only as a further element, never instead of Ed25519 |
A verification profile names required_algs (default [Ed25519]),
min_anchors (an integer >= 1, default 1) and optionally now. A document
passes only if every required algorithm is covered by a valid signature of
a key from the set given for that document. An unknown alg, a bad encoding,
a kid outside the set, or a signature that does not verify MUST cause
rejection -- a failing signature is a refusal, never a gap in coverage.¶
A timestamp is exactly YYYY-MM-DDTHH:MM:SS.mmmZ: UTC, three digits of
milliseconds, 24 characters [RFC3339]. Any other form -- no fraction, another
fraction length, an offset, a leap second :60 -- MUST be rejected. With one
fixed form, the order of the strings is the order of the moments.¶
Moments are compared as milliseconds since 1970-01-01T00:00:00Z, not as strings. The calendar is checked: month 1-12, day within the month, leap years.¶
A party is identified by an opaque URI; the core does not interpret it.¶
The container identifier is derived at initiation and re-derived at verification:¶
container = SHA-256( F("ksg:container-id:v1") || F(emission)
|| F(issued_at) || F(serial, 8-byte big-endian)
|| F(agent key) || F(artifact)
|| F(offset_ms, 8-byte big-endian) )
F(x) = (length of x, 4-byte big-endian) || x
¶
where emission is the release id, issued_at the release's timestamp
string, agent key the base64url key string of the client agent, artifact
the artifact URI. The result is written as a multihash. A ContainerInit
whose container differs from the derived value MUST be rejected: the
identifier is evidence, not a claim.¶
{ "type": "<network URI>", "subject": "1220...",
"proof": "<base64url>", "anchored_at": "..." }
¶
The core does not parse proof. A network reader, selected by type,
confirms that subject is recorded in the network and returns the moment the
network proves. anchored_at is a statement, not evidence.¶
One procedure applies to everything that is anchored -- a journal seal, a status record, a release:¶
subject equal to the object's hash; otherwise
reject;¶
min_anchors (>= 1) anchors MUST be confirmed; with fewer, the
object has no upper bound -- there is no bound "by an operator's word";¶
All channel documents are major version 3 with the envelope of Section
"Canonical Form and Envelope". Their JSON Schemas are published at
https://schema.keysingate.com/core/v2/ (Appendix "Schemas").¶
Members: id (ksg:em: followed by digits), range {from, to},
block_ttl_days (>= 1), issuer, issued_at, keys, signatures.
Self-signed. Trust goes only to keys present both in the release and in
the verifier's own set of trusted issuer keys. issued_at is the lower time
bound of everything under the release.¶
Lifetime. A block may be opened for base * (10 + d) / 10 milliseconds,
where base = block_ttl_days * 86 400 000 and d is the depth of the channel
the block passed through (0 for a block sold by the issuer). Integer
arithmetic, saturating. The result MUST NOT exceed 730 days
(63 072 000 000 ms) whatever the release declares. The allowance per level
compensates the time a block spends travelling down the channel; the ceiling
bounds how long unopened stock may wait.¶
The right to allocate blocks inside a sub-range. Members: emission,
range, delegate, keys, depth (0-4), parent (hash of the delegation
above; absent at depth 0), delegated_at, term_ms, expires_at,
signatures.¶
term_ms MUST NOT exceed 180 days; expires_at MUST be later than
delegated_at and MUST NOT be later than the parent's;¶
now in the profile, an expired or not yet started link MUST cause
rejection; without now, a report MUST NOT state that the authority was in
force.¶
Members: emission, block {from, to}, class, holder, allocated_at,
expires_at, prev_closure (hash of the previous block's closure; REQUIRED
from the holder's second block), signatures.¶
heavy 1-1 000; light 10 000-100 000; bare is not
allocated in blocks;¶
used * 5 >= size * 4);¶
expires_at takes a
container out of the term for good; after it, the container's status is 20;
serials never initiated reach status 10.¶
Members: emission, block, used_submitted, used_not_submitted,
cancelled, closed_at, signatures (the holder's). The three sets MUST
cover the block exactly, without overlap; for heavy, used_not_submitted
MUST be empty.¶
Members: emission, block, agent, bound_at, signatures. A one-time
binding of a block to the holder's agent key; a second binding of the same
block is invalid.¶
Members: emission, serial, binding (hash of the binding), agent,
artifact, offset_ms, container, signatures. The client agent's key and
the artifact are bound at one and the same moment. Signatures of the agent and
of a second party (the owner or an orchestrator) -- at least two distinct
parties. The serial MUST lie in the binding's block; the offset MUST NOT run
past the block's lifetime; container MUST equal the derived identifier.¶
A key is never replaced; a new one is laid on top. Members: emission,
block, predecessor (hash of the binding for the first, of the previous link
after that), key, offset_ms, signatures (by the previous link's key). The
valid set is the binding's key and every key of the unbroken chain; all stay
valid. At most 1 024 links.¶
Members: container, from, to, offset_ms, signatures (owner and
issuer, at least two). Only upward: bare < light < heavy.¶
A class is a tariff and the form in which a container's journal opens, not an anchoring mode for life:¶
| Class | Journal opens as | May turn to anchored form | Anchors |
|---|---|---|---|
heavy
|
anchored ("Pro") | -- | every seal |
light
|
unanchored ("lite") | yes, by a form-change page | only while anchored |
bare
|
unanchored | no | never |
A container's life is a sequence of statuses:¶
| Status | Meaning | Status | Meaning |
|---|---|---|---|
| 0 | printed | 12 | opened voluntarily |
| 1 | block applied | 20 | initiated after the term |
| 2 | buyer's key applied | 21 | prescription passed |
| 3 | sub-agent's key applied | 22 | key lost |
| 4 | initiated | 30 | in dispute |
| 5 | in work | 31 | dispute proven |
| 6 | frozen | 32 | dispute unproven |
| 7 | exported | 34 | appealed |
| 9 | lost | 35 | settled |
| 10 | never activated | 8, 33 | reserved, no transitions |
Each change is a StatusRecord: number (0 for the printing, then +1),
status (after the change), event (absent in record 0), offset_ms,
prev, signatures. Record 0's prev is the section root:¶
root = SHA-256( F("ksg:status-section:v1") || F(emission)
|| F(serial, 8-byte big-endian) )
¶
-- the serial's address, known before the container exists. Every later record
names its predecessor's hash. A change is valid only if the transition table
allows it from the previous status (Appendix "Transition Table"); the table
is normative and is also published as test vector 08_status_table.¶
A status record is anchored, and its anchor is read by the Bound Rule; without a confirmed anchor a status has no time, and a report MUST say so.¶
Status section export. The release, the serial and every record with its anchors, in order. Not signed as a whole: each record is signed and anchored, and the chain from the root holds the order. Verification repeats acceptance: record 0 from the address, every next record through the transition table, linked, signed, anchored; then every anchor by the Bound Rule.¶
Input: the object (a journal export, per the companion document, or a status section), the container identifier, the class or the channel it derives from, the keys, a profile, network readers.¶
The report states accepted or rejected with a reason, and lists explicitly what is not established. A rejection is never replaced by a weakened result.¶
Documents of this core are major version 3; an implementation of this core issues and verifies only that version. Adding an optional member is a minor change; changing the meaning or requiredness of a member is a new major version.¶
| What | Limit |
|---|---|
| core document, bytes | 64 KiB |
| journal export, bytes | 8 MiB |
| signatures per document | 8 |
| anchors per object | 16 |
| Merkle path, links | 64 |
| delegation links | 5 (depths 0-4) |
| key inclusion links | 1 024 |
| status section records | 1 024 |
Exceeding a limit MUST cause rejection before any signature is checked: a verifier does not take on unbounded work.¶
Where a set can be accepted, do not choose: signatures, anchors and identifiers are arrays and opaque URIs. New things are added as elements, not by replacing a member. An extension MUST NOT change the meaning or requiredness of a core member.¶
Time. The only upper bound is a confirmed anchor. An implementation that
falls back to anchored_at, to a server clock or to any operator's statement
when no anchor is confirmed violates this document; Rule 4 exists to make that
fallback impossible to express.¶
Fail-closed. Anything unread, unknown or over a limit is a rejection. Strict member parsing and the integer rule exist because a document that two implementations read differently is an attack surface, not a compatibility issue.¶
Channel trust. A release is self-signed; trust comes from the verifier's
own set of issuer keys, never from the document. A delegation chain is only as
valid as its weakest link, and without now no statement about authority in
force is made.¶
Post-quantum. ML-DSA-65 is added as a further signature, never as a
replacement: an implementation without it still verifies Ed25519, and one
requiring it states so in required_algs.¶
Implementation. Verification keeps no state between calls, and library code of the core returns every error as a value rather than aborting.¶
Party identifiers are opaque URIs; the core neither resolves nor requires personal data. A container identifier is a hash and reveals the artifact only to someone who already holds it. Anchors may be blind (Section "Networks"): the network then shows only that something was written.¶
This document has no IANA actions. urn:keysingate:core:v3 is used as an
opaque, byte-compared string; no URN namespace is registered by this
document.¶
JSON Schemas of every document in Section "Channel Documents" and of the
status record are published at https://schema.keysingate.com/core/v2/
(common, emission, delegation, allocation, closure, binding,
container-init, key-inclusion, class-promotion, status-record). A
schema constrains shape, not rules: coverage, uniqueness, time bounds and
signatures lie outside it. An implementation that has checked only the schema
has not checked the document.¶
Generated from the test vector 08_status_table.json, which is
generated from the reference implementation. Every pair not listed is
refused. Events are those of the StatusRecord schema.¶
| From | Event | Outcome |
|---|---|---|
| 0 |
apply_packet
|
-> 1 |
| 1 |
apply_buyer_key
|
-> 2 |
| 1 |
initiate_too_late
|
-> 20 |
| 1 |
packet_term_expired
|
-> 10 |
| 2 |
apply_sub_agent_key
|
-> 3 |
| 2 |
initiate
|
-> 4 |
| 2 |
initiate_too_late
|
-> 20 |
| 2 |
packet_term_expired
|
-> 10 |
| 3 |
initiate
|
-> 4 |
| 3 |
initiate_too_late
|
-> 20 |
| 3 |
packet_term_expired
|
-> 10 |
| 4 |
open_journal
|
-> 5 |
| 4 |
declare_key_lost
|
-> 22 |
| 4 |
declare_container_lost
|
-> 9 |
| 4 |
serve_dispute
|
-> 30 |
| 5 |
record_work
|
stays |
| 5 |
record_artifact_or_rights
|
stays |
| 5 |
transfer_ownership
|
stays |
| 5 |
raise_class
|
stays |
| 5 |
full_export
|
-> 7 |
| 5 |
finalize
|
-> 6 |
| 5 |
open_voluntarily
|
-> 12 |
| 5 |
freeze_on_owner_application
|
-> 6 |
| 5 |
succeed
|
-> 6 |
| 5 |
declare_key_lost
|
-> 22 |
| 5 |
declare_container_lost
|
-> 9 |
| 5 |
serve_dispute
|
-> 30 |
| 6 |
full_export
|
-> 7 |
| 6 |
open_voluntarily
|
-> 12 |
| 6 |
succeed
|
stays |
| 6 |
declare_key_lost
|
-> 22 |
| 6 |
declare_container_lost
|
-> 9 |
| 6 |
serve_dispute
|
-> 30 |
| 12 |
record_work
|
stays |
| 12 |
record_artifact_or_rights
|
stays |
| 12 |
transfer_ownership
|
stays |
| 12 |
raise_class
|
stays |
| 12 |
full_export
|
-> 7 |
| 12 |
finalize
|
-> 6 |
| 12 |
freeze_on_owner_application
|
-> 6 |
| 12 |
succeed
|
-> 6 |
| 12 |
declare_key_lost
|
-> 22 |
| 12 |
declare_container_lost
|
-> 9 |
| 12 |
serve_dispute
|
-> 30 |
| 30 |
record_work
|
stays |
| 30 |
record_artifact_or_rights
|
stays |
| 30 |
transfer_ownership
|
stays |
| 30 |
raise_class
|
stays |
| 30 |
full_export
|
-> 7 |
| 30 |
open_voluntarily
|
-> 12 |
| 30 |
freeze_on_owner_application
|
-> 6 |
| 30 |
succeed
|
-> 6 |
| 30 |
dispute_proven
|
-> 31 |
| 30 |
dispute_unproven
|
-> 32 |
| 30 |
extend_review
|
stays |
| 30 |
review_deadline_passed
|
blocks |
| 30 |
settle
|
-> 35 |
| 30 |
prescription_passed
|
-> 21 |
| 31 |
record_work
|
stays |
| 31 |
record_artifact_or_rights
|
stays |
| 31 |
transfer_ownership
|
stays |
| 31 |
raise_class
|
stays |
| 31 |
full_export
|
-> 7 |
| 31 |
open_voluntarily
|
-> 12 |
| 31 |
freeze_on_owner_application
|
-> 6 |
| 31 |
succeed
|
-> 6 |
| 31 |
appeal
|
-> 34 |
| 32 |
record_work
|
stays |
| 32 |
record_artifact_or_rights
|
stays |
| 32 |
transfer_ownership
|
stays |
| 32 |
raise_class
|
stays |
| 32 |
full_export
|
-> 7 |
| 32 |
open_voluntarily
|
-> 12 |
| 32 |
freeze_on_owner_application
|
-> 6 |
| 32 |
succeed
|
-> 6 |
| 34 |
record_work
|
stays |
| 34 |
record_artifact_or_rights
|
stays |
| 34 |
transfer_ownership
|
stays |
| 34 |
raise_class
|
stays |
| 34 |
full_export
|
-> 7 |
| 34 |
open_voluntarily
|
-> 12 |
| 34 |
freeze_on_owner_application
|
-> 6 |
| 34 |
succeed
|
-> 6 |
| 34 |
appeal_won
|
stays |
| 34 |
appeal_lost
|
-> 31 |
| 34 |
extend_review
|
stays |
| 34 |
review_deadline_passed
|
blocks |
| 34 |
settle
|
-> 35 |
| 34 |
prescription_passed
|
-> 21 |
| 35 |
record_work
|
stays |
| 35 |
record_artifact_or_rights
|
stays |
| 35 |
transfer_ownership
|
stays |
| 35 |
raise_class
|
stays |
| 35 |
full_export
|
-> 7 |
| 35 |
open_voluntarily
|
-> 12 |
| 35 |
freeze_on_owner_application
|
-> 6 |
| 35 |
succeed
|
-> 6 |
While a container is blocked awaiting a decision, whatever its status:¶
| Event | Outcome |
|---|---|
dispute_proven
|
-> 31 |
dispute_unproven
|
-> 32 |
settle
|
-> 35 |
prescription_passed
|
-> 21 |
Terminal statuses: 7, 9, 10, 20, 22, 21. Working statuses: 5, 12, 30, 31, 32, 34, 35.¶
Per [RFC7942]. ksg-core-v2 (Rust) implements this document; with the
crates around it the workspace has over 750 tests; 13 test-vector sets are
regenerated and compared byte for byte by the reference build. The verifier ksg-verify-v2 checks the
channel from release to container identifier and status sections with anchors
read through a Solana network reader.¶