Skip to main content

Keys and Signatures (Key)

A signature proves who wrote something, and that it hasn't changed since. Key makes a secret key, gives you its public key to share, signs messages, and checks signatures. Your app has the same functions (StofDoc.sign and friends in JavaScript, Doc.sign in Python, stof::model::seal::sign in Rust), so a signature made in one place checks in any other.

Keys and signatures are plain base64 strings: they travel in JSON, environment variables, and headers. The scheme is Ed25519.

Two rules make signatures hard to misuse:

  • What's signed is a message's exact bytes (a string, or a blob). Change one character and the signature no longer checks. To sign an object, sign its export (stringify('json', obj)) and send that exact text.
  • Every signature has a context, a short name for what it's for ('orders/v1', 'refunds/v1'). It only checks under the same context, so a signature made for one purpose can't be passed off as another. Seal signatures, over a digest, use 'stof/seal'.

Key::generate() -> str​

A new secret key: 32 bytes from the system's secure random source. Keep it secret. Most of the time your app holds secret keys and passes one in when a document needs to sign; if a document has to keep one, encrypt it with Age.

generate.stofPlayground ↗
#[main]
fn main() {
  const secret = Key::generate();
  pln(secret);
  pln(secret == Key::generate()); // false: a new one every time
}
Output

Key::public(secret: str) -> str​

The public key for a secret key. Share it: anyone with it can check your signatures, but can't make them.

public.stofPlayground ↗
#[main]
fn main() {
  const secret = Key::generate();
  pln(Key::public(secret));
}
Output

Key::sign(secret: str, context: str, message: str | blob) -> str​

Sign a message under a context. Returns the signature.

sign.stofPlayground ↗
order: { item: 'lamp', qty: 2 }

#[main]
fn main() {
  const secret = Key::generate();
  const text = stringify('json', self.order); // sign the exact text you send
  const signature = Key::sign(secret, 'orders/v1', text);
  pln(text);
  pln(signature);
}
Output

Key::verify(public: str, context: str, message: str | blob, signature: str) -> bool​

True only when signature was made over exactly these bytes, under this context, with the secret key behind public. Anything else is false, including a key or signature that isn't one.

verify.stofPlayground ↗
#[main]
fn main() {
  const secret = Key::generate();
  const public = Key::public(secret);
  const order = '{"item":"lamp","qty":2}';
  const signature = Key::sign(secret, 'orders/v1', order);

  pln(Key::verify(public, 'orders/v1', order, signature));                    // true
  pln(Key::verify(public, 'orders/v1', order.replace('2', '20'), signature)); // false: changed
  pln(Key::verify(public, 'refunds/v1', order, signature));                   // false: another purpose
  pln(Key::verify(Key::public(Key::generate()), 'orders/v1', order, signature)); // false: another key
}
Output

Checking Data You Receive​

Hold the sender's public key, and check before you use what they sent:

accept.stofPlayground ↗
shop: {
  key: '11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo='   // the shop's public key
  order: {}

  fn accept(order: str, signature: str) -> bool {
      if (!Key::verify(self.key, 'orders/v1', order, signature)) return false;
      parse(order, self.order, 'json'); // trusted now
      true
  }
}

#[main]
fn main() {
  const order = '{"item":"lamp","qty":2}';
  const signature = 'Qr/vFkG4ZLv/M1JwvCdDjSb107rdqjXLjbE044YT2U712OHn2obDLuLns6JNo06QBqCe5ENesDH02ZL4uQqVDQ==';
  pln(self.shop.accept(order, signature));
  pln(self.shop.order.qty);
}
Output

Signing Logic You Ship​

A seal signature is a signature over a part's digest, under 'stof/seal'. Your app checks it when it seals the part (doc.seal(path, { publicKey, signature })), which is this check followed by the seal:

seal-signature.stofPlayground ↗
rules: {
  fn discount(total: float) -> float { total * 0.9 }
}

#[main]
fn main() {
  const secret = Key::generate();
  const signature = Key::sign(secret, 'stof/seal', digest(self.rules));
  pln(Key::verify(Key::public(secret), 'stof/seal', digest(self.rules), signature));
}
Output

With Age​

Key signs; Age encrypts. They're separate keys for separate jobs. Together: encrypt to the reader so only they can open it, and sign what you encrypted so they know it came from you.

sign-and-encrypt.stofPlayground ↗
message: { text: 'meet at noon' }

#[main]
fn main() {
  const reader = Age::generate();    // the reader's encryption identity
  const sender = Key::generate();    // the sender's signing key

  const box = Age::blobify(Age::public(reader), 'json', self.message);
  const signature = Key::sign(sender, 'messages/v1', box);

  if (Key::verify(Key::public(sender), 'messages/v1', box, signature)) {
      const opened = new {};
      Age::parse(reader, box, opened, 'json');
      pln(opened.text);
      drop(opened);
  }
}
Output

In Your App​

JavaScriptPythonRust
New secret keyStofDoc.generateKey()Doc.generate_key()seal::generate_key()
Public keyStofDoc.publicKey(secret)Doc.public_key(secret)seal::public_key(secret)
SignStofDoc.sign(secret, context, message)Doc.sign(secret, context, message)seal::sign(secret, context, bytes)
VerifyStofDoc.verify(public, context, message, signature)Doc.verify(public, context, message, signature)seal::verify(public, context, bytes, signature)

A message is a string or bytes (Uint8Array in JavaScript, bytes in Python, &[u8] in Rust). In Rust, seal is stof::model::seal.