feat(base,app,keys): keys of shared spaces, sealed to each member

Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.

- halkyon_keys: the safety code of two accounts' public keys
  (`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
  (`public-keys-of`, local and remote); the table `shared_space_key`
  and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
  mine). Only the Owner and Editors seal, the first key sealed to a
  member stays, and a member's keys go with their membership. Every
  host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
  opens one sealed by another; `SharedKeys` shares a space's key with
  its members, seals keys to newcomers and opens the person's own,
  holding each member to the public keys `KeyPins` pinned on first
  sight (`key_pins.json` in the person's folder), a change refused
  until trusted anew.

Learn takes them into its `Privacy` in step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 11:28:13 +01:00
commit b72cea1fe0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 105813 additions and 5 deletions

View file

@ -31,4 +31,5 @@ export 'src/recovery_phrase.dart'
recoveryProofKeysOf,
recoveryProofPublicKeyOf;
export 'src/row_ids.dart' show keyedRowId;
export 'src/safety_code.dart' show PublicKeys, safetyCode;
export 'src/sealed_content.dart' show SealedContent, openContent, sealContent;

View file

@ -0,0 +1,60 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:sodium/sodium_sumo.dart';
/// Safety codes (Halkyon's ADR 0044, D18): two people sharing content check
/// that the instance handed each of them the other's real public keys by
/// comparing a code made of both, in person or by call, as Signal's safety
/// numbers are compared. Each person's half is 30 digits worked out from
/// their two public keys alone; the code puts the two halves in one order,
/// the lower first, so both see the same 60 digits.
/// What each half's hash binds itself to: a label naming the format.
final _label = utf8.encode('halkyon safety code v1');
/// How often each half's hash is taken: as Signal's, so that finding other
/// keys with the same half costs a little more than once.
const _rounds = 5200;
/// An account's public keys: X25519's, which data keys are sealed to, and
/// Ed25519's, which its signatures check against.
typedef PublicKeys = ({Uint8List encryption, Uint8List signing});
/// The safety code of [one] and [other], the same whichever is which: 60
/// digits in twelve groups of five, separated by spaces.
String safetyCode(SodiumSumo sodium, PublicKeys one, PublicKeys other) {
final halves = [_half(sodium, one), _half(sodium, other)]..sort();
final digits = halves.join();
return [
for (var i = 0; i < digits.length; i += 5) digits.substring(i, i + 5),
].join(' ');
}
/// One person's 30 digits: BLAKE2b over the label and their keys, taken
/// [_rounds] times over itself and the keys, read as six numbers of five
/// bytes, each below 100,000.
String _half(SodiumSumo sodium, PublicKeys keys) {
final keyBytes = [
keys.encryption.length,
...keys.encryption,
keys.signing.length,
...keys.signing,
];
var hash = Uint8List.fromList([..._label, ...keyBytes]);
for (var round = 0; round < _rounds; round++) {
hash = sodium.crypto.genericHash(
message: Uint8List.fromList([...hash, ...keyBytes]),
outLen: 32,
);
}
final digits = StringBuffer();
for (var chunk = 0; chunk < 6; chunk++) {
var number = 0;
for (var byte = 0; byte < 5; byte++) {
number = number * 256 + hash[chunk * 5 + byte];
}
digits.write((number % 100000).toString().padLeft(5, '0'));
}
return digits.toString();
}