SpruceID
Search…
Architectural Overview
Rebase is a library for handling the witnessing of cryptographically verifiable claims, and the issuance of Verifiable Credentials (VC) based on this programmatic witnessing. Rebase simplifies the process of creating links between identity providers, or self-attested claims using VCs by providing a convenient wrapper around ssi. Rebase is intended for a wide variety of uses ranging from server-side "witness" services, to VC reading validation services, to in-browser usage via WASM.

Architectural Overview

The heart of the project is found in rust/rebase/src. The high-level goal of this implementation is to receive data from the end-user, create a statement for the user to sign, ask for the signature from the user (in addition to some other information in some cases), and presuming the statement and the signature match, issue a credential. Some flows are simpler than others, but all follow this basic format.
Rebase works by layering several abstractions over each other. At the base is the SignerType, which defines what cryptographic signature could be read in a claim and how it could be verified. A layer above that is the Signer<T: SignerType> which is a struct capable of signing both plain text (in the case of a client) and a VC (in the case of an issuer).
In the simplest flow, the issuer is the client, but these types of claims don't link identities, simply show the signer signed whatever is stated in the VC (in other words "self-attested").
The next important abstraction is the SchemaType which is a trait that takes a simple struct, something like:
// src/witness/github.rs
pub struct Schema {
pub gist_id: String,
pub handle: String,
pub key_type: SignerDID,
pub statement: String,
pub signature: String,
}
Then implements the following portion of this trait to generate the pieces of the VC from the given SchemaType:
// src/schema/schema_type
pub trait SchemaType {
// ...
// Return the @context contents based enum variant
fn context(&self) -> Result<serde_json::Value, SchemaError>;
​
// Returns the evidence entry for VC
fn evidence(&self) -> Result<Option<OneOrMany<Evidence>>, SchemaError>;
​
// TODO: Better type?
// Returns the object used in credentialSubject
fn subject(&self) -> Result<serde_json::Value, SchemaError>;
​
// Return the types used in credential building.
fn types(&self) -> Result<Vec<String>, SchemaError>;
}
The result is that the following functions are derived:
// Return the unsigned credential using a signer type.
async fn unsigned_credential<T: SignerType>(
&self,
signer_type: &T,
) -> Result<Credential, SchemaError> {
// ...
}
​
// Return the complete, signed LD Proof credential
async fn credential<T: SignerType>(
&self,
signer: &dyn Signer<T>,
) -> Result<Credential, SchemaError> {
// ...
}
​
// Return a JWT signed credential
async fn jwt<T: SignerType>(&self, signer: &dyn Signer<T>) -> Result<String, SchemaError> {
// ...
}
Because the SignerType provides one portion of the VC's construction, and the SchemaType provides the rest, and that the Signer<T: SignerType> provides the signature to a given SchemaType, all of these pieces can be mixed and matched. If a new SchemaType is implemented, it works with all existing Signer/SignerTypes. If a new Signer is implemented, it works with all existing SchemaTypes.
The final set of abstractions are a toolkit for building witnessing services.

Witness Flows

The witnessing flow looks like:
  1. 1.
    Gather information from the user to give data for a statement.
  2. 2.
    Give the user a statement to sign that describes the SignerType that should be used to sign the statement.
  3. 3.
    The user signs the statement. The user returns the statement and enough information to verify the signature. In the case of linking public profiles, this would be retrieving a public post (a tweet, a gist, etc) that contains the statement and signature, parsing them, then verifying that signature is of the statement and by the SignerType described in the statement. In the case of linking two keys, this would just be the two SignerTypes and two signatures.
  4. 4.
    The witness performs the steps described above and either issues a VC or returns an error.
To make this possible, first, a struct must implement the Statement trait in src/witness/witness, then when a user supplies such a struct, they are given back a statement to sign and a delimiter (if applicable) to place between the statement and the signature.
Once the user has the statement to sign, then they often have to post the combination of format!("{}{}{}", statement, delimiter, signature) (DNS is an exception to this rule, using a prefix and format!("{}{}{}", prefix, delimiter, signature)). Once they have posted the statement (if necessary), they then have to provide enough information to create a struct that implements Proof. Proof must implement Statement to allow the witness to make sure that the statement found is the same as expected. Often, the same struct implements Proof and SchemaType.
The final abstraction is the witness, contained in the Generator trait. This trait requires the user to implement a pair of functions:
// From the proof structure, create a schema.
async fn schema(&self, proof: &P) -> Result<S, WitnessError> {
let post = self.locate_post(proof).await?;
let (statement, signature) = proof.parse_post(&post).await?;
Ok(self._unchecked_to_schema(proof, &statement, &signature)?)
}
​
// From the proof structure, create a LD credential.
async fn credential<T: SignerType>(
&self,
proof: &P,
signer: &dyn Signer<T>,
) -> Result<Credential, WitnessError> {
Ok(self.schema(proof).await?.credential(signer).await?)
}
​
// From the proof structure, create a JWT.
async fn jwt<T: SignerType>(
&self,
proof: &P,
signer: &dyn Signer<T>,
) -> Result<String, WitnessError> {
Ok(self.schema(proof).await?.jwt(signer).await?)
}
Which then derives the following functions:
// src/witness/witness
#[async_trait(?Send)]
pub trait Generator<P: Proof, S: SchemaType> {
// From the proof structure, look up the statement and signature.
async fn locate_post(&self, proof: &P) -> Result<String, WitnessError>;
​
// From the proof structure, create a schema structure without any checks.
fn _unchecked_to_schema(
&self,
proof: &P,
statement: &str,
signature: &str,
) -> Result<S, WitnessError>;
...
}
This allows a witness to be as simple as a struct that implements Generator to receive a valid Proof and return a Schema, a Credential, or a JWT String depending on what is requested. The derived schema function only allows the creation of credentials if they pass the parsing stage.
In the case of DNS, the Generator is an empty struct, in the case of Twitter, the Generator has an api_key field. Any required information for the post retrieval process can be specified in a struct, then that struct made to implement Generator.
To maximize the ability to mix and match credentials several helper structs can be found in src/witness, specifically ProofTypes, StatementTypes and SignerTypes, these are two enums that encompass all supported Proofs and SignerTypes, then implement Proof and SignerType on the enum by calling their inner, concrete representation.
Similiarly, in src/signer/signer there is a DID enum which captures all the supported SignerTypes in a generic struct. To implement SignerType, it's required to have the following function implemented:
fn new(t: &DID) -> Result<Self, SignerError>;
This allows us to capture all valid SignerTypes in src/signer/signer but not have circular dependencies, and also allows for easy conversion back and forth between DID and SignerType.
The useful result of these enum abstractions is the ability to create a universal generator, available for import from src/witness/generator. Given a supported Proof (i.e. those listed in ProofTypes) and a supported SignerType (i.e. those listed in SignerTypes), the generator can validate a claim and produce a VC.
Statements work similarly with StatementTypes and SignerTypes. Thus, the calling application doesn't even have to be aware of all the possible claims it can validate -- seen in the example worker.
Copy link